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,22 @@
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services import (
DeliveryGenerationRequest,
DeliverySendRequest,
DeliveryService,
NormalizedSchedule,
SubscriptionManagementService,
SubscriptionScanner,
parse_schedule,
)
__all__ = [
"DeliveryGenerationRequest",
"DeliverySendRequest",
"DeliveryService",
"NormalizedSchedule",
"PushDelivery",
"PushSubscription",
"SubscriptionManagementService",
"SubscriptionScanner",
"parse_schedule",
]

View File

@@ -0,0 +1,65 @@
from enum import StrEnum
class SubscriptionTargetType(StrEnum):
USER = "user"
CHAT = "chat"
class SubscriptionScheduleType(StrEnum):
ONCE = "once"
DAILY = "daily"
WEEKDAY = "weekday"
WEEKLY = "weekly"
MONTHLY = "monthly"
INTERVAL = "interval"
class PushSubscriptionStatus(StrEnum):
ACTIVE = "active"
PAUSED = "paused"
CANCELLED = "cancelled"
COMPLETED = "completed"
class PushDeliveryStatus(StrEnum):
PENDING = "pending"
PROCESSING = "processing"
RETRY = "retry"
SENT = "sent"
FAILED = "failed"
SKIPPED = "skipped"
class SubscriptionAuditAction(StrEnum):
CREATE = "subscription.create"
PAUSE = "subscription.pause"
RESUME = "subscription.resume"
CANCEL = "subscription.cancel"
UPDATE_TIMEZONE = "subscription.update_timezone"
UPDATE_QUIET_HOURS = "subscription.update_quiet_hours"
DELIVERY_SENT = "subscription.delivery.sent"
DELIVERY_FAILED = "subscription.delivery.failed"
DELIVERY_SKIPPED = "subscription.delivery.skipped"
DEFAULT_SUBSCRIPTION_TIMEZONE = "Asia/Shanghai"
MAX_ACTIVE_SUBSCRIPTIONS = 50
MAX_DAILY_DELIVERIES = 96
MIN_INTERVAL_MINUTES = 15
DELIVERY_RETRY_DELAYS_SECONDS = (60, 300, 900)
SUBSCRIPTION_LEASE_SECONDS = 120
DELIVERY_LEASE_SECONDS = 300
SUBSCRIPTION_NOT_FOUND = "Subscription not found"
DELIVERY_NOT_FOUND = "Subscription delivery not found"
SUBSCRIPTION_LIMIT_REACHED = "A user may enable at most 50 subscriptions"
DAILY_DELIVERY_LIMIT_REACHED = "Daily delivery limit reached"
INVALID_SCHEDULE = "Unsupported or invalid schedule expression"
INVALID_TIMEZONE = "Invalid IANA timezone"
INVALID_QUIET_HOURS = "Quiet hours must use different HH:MM start and end values"
INVALID_PRIVATE_TARGET = "Private subscriptions must target the current user's open_id"
INVALID_GROUP_TARGET = "Group subscriptions must be created by an administrator in the current group"
INACTIVE_USER = "Feishu user is disabled"
EMPTY_PROMPT = "Subscription prompt is required"

View File

@@ -0,0 +1,122 @@
from datetime import datetime
from typing import Any
from sqlalchemy import (
JSON,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.subscriptions.constants import (
PushDeliveryStatus,
PushSubscriptionStatus,
)
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("feishu_users.id", ondelete="CASCADE"),
index=True,
)
target_type: Mapped[str] = mapped_column(String(16), index=True)
target_id: Mapped[str] = mapped_column(String(256))
prompt: Mapped[str] = mapped_column(Text)
schedule_type: Mapped[str] = mapped_column(String(32), index=True)
schedule_config: Mapped[dict[str, Any]] = mapped_column(JSON)
timezone: Mapped[str] = mapped_column(String(64))
next_run_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(
String(32),
default=PushSubscriptionStatus.ACTIVE,
index=True,
)
consented_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
locked_until: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=utc_now,
onupdate=utc_now,
)
deliveries: Mapped[list["PushDelivery"]] = relationship(
back_populates="subscription",
cascade="all, delete-orphan",
passive_deletes=True,
)
class PushDelivery(Base):
__tablename__ = "push_deliveries"
__table_args__ = (
UniqueConstraint(
"subscription_id",
"scheduled_for",
name="uq_push_delivery_subscription_schedule",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
subscription_id: Mapped[int] = mapped_column(
ForeignKey("push_subscriptions.id", ondelete="CASCADE"),
index=True,
)
scheduled_for: Mapped[datetime] = mapped_column(DateTime, index=True)
idempotency_key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
message_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
status: Mapped[str] = mapped_column(
String(32),
default=PushDeliveryStatus.PENDING,
index=True,
)
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
rendered_content: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_message_id: Mapped[str | None] = mapped_column(
String(256),
nullable=True,
index=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
locked_until: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=utc_now,
onupdate=utc_now,
)
subscription: Mapped[PushSubscription] = relationship(back_populates="deliveries")

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_api_key
from app.modules.subscriptions.schemas import PushDeliveryRead, PushSubscriptionRead
from app.modules.subscriptions.services.management import SubscriptionManagementService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("")
def list_subscriptions(
status_filter: str | None = None,
owner_id: int | None = None,
limit: int = 100,
offset: int = 0,
db: Session = Depends(get_db),
) -> dict:
total, records = SubscriptionManagementService(db).list_all(
status_filter=status_filter,
owner_id=owner_id,
limit=limit,
offset=offset,
)
return {
"total": total,
"items": [PushSubscriptionRead.model_validate(item) for item in records],
}
@router.get("/deliveries")
def list_deliveries(
status_filter: str | None = None,
subscription_code: str | None = None,
limit: int = 100,
offset: int = 0,
db: Session = Depends(get_db),
) -> dict:
total, records = SubscriptionManagementService(db).list_deliveries(
status_filter=status_filter,
subscription_code=subscription_code,
limit=limit,
offset=offset,
)
return {
"total": total,
"items": [PushDeliveryRead.model_validate(item) for item in records],
}

View File

@@ -0,0 +1,55 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class NormalizedScheduleRead(BaseModel):
schedule_type: str
schedule_config: dict[str, Any]
timezone: str
next_run_at: datetime
display: str
class SubscriptionCreate(BaseModel):
prompt: str = Field(min_length=1, max_length=8000)
schedule: str = Field(min_length=1, max_length=256)
class PushSubscriptionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
code: str
owner_id: int
target_type: str
target_id: str
prompt: str
schedule_type: str
schedule_config: dict[str, Any]
timezone: str
next_run_at: datetime | None
status: str
consented_at: datetime
last_run_at: datetime | None
created_at: datetime
updated_at: datetime
class PushDeliveryRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
code: str
subscription_id: int
scheduled_for: datetime
idempotency_key: str
message_uuid: str
status: str
attempt_count: int
next_attempt_at: datetime | None
provider_message_id: str | None
last_error: str | None
sent_at: datetime | None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,39 @@
from app.modules.subscriptions.services.delivery import (
DeliveryGenerationRequest,
DeliveryGenerator,
DeliverySendRequest,
DeliverySender,
DeliveryService,
PermanentDeliveryError,
RetryableDeliveryError,
)
from app.modules.subscriptions.services.management import SubscriptionManagementService
from app.modules.subscriptions.services.scanner import SubscriptionScanner
from app.modules.subscriptions.services.schedule import (
NormalizedSchedule,
ScheduleParseError,
is_in_quiet_hours,
next_occurrence,
next_quiet_end,
parse_schedule,
validate_timezone,
)
__all__ = [
"DeliveryGenerationRequest",
"DeliveryGenerator",
"DeliverySendRequest",
"DeliverySender",
"DeliveryService",
"NormalizedSchedule",
"PermanentDeliveryError",
"RetryableDeliveryError",
"ScheduleParseError",
"SubscriptionManagementService",
"SubscriptionScanner",
"is_in_quiet_hours",
"next_occurrence",
"next_quiet_end",
"parse_schedule",
"validate_timezone",
]

View File

@@ -0,0 +1,645 @@
from dataclasses import dataclass
from datetime import UTC, datetime, time, timedelta
from typing import Any, Protocol
from uuid import uuid4
from zoneinfo import ZoneInfo
import httpx
from fastapi import HTTPException, status
from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.orm import Session
from app.core.http.pagination import bounded_limit
from app.core.utils.time import utc_now
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.feishu.errors import FeishuAPIError
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
from app.modules.feishu_users.models import FeishuUser
from app.modules.subscriptions.constants import (
DAILY_DELIVERY_LIMIT_REACHED,
DELIVERY_LEASE_SECONDS,
DELIVERY_NOT_FOUND,
DELIVERY_RETRY_DELAYS_SECONDS,
MAX_DAILY_DELIVERIES,
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionAuditAction,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services.schedule import (
is_in_quiet_hours,
next_quiet_end,
)
@dataclass(frozen=True, slots=True)
class DeliveryGenerationRequest:
prompt: str
owner_id: int | None
use_personal_context: bool
use_company_rules: bool
allow_tools: bool = False
record_history: bool = False
infer_preferences: bool = False
write_memory: bool = False
@dataclass(frozen=True, slots=True)
class DeliverySendRequest:
receive_id: str
receive_id_type: str
tenant_key: str
text: str
uuid: str
class DeliveryGenerator(Protocol):
def generate(self, request: DeliveryGenerationRequest) -> str:
"""Generate delivery text without side effects or business-data tools."""
class DeliverySender(Protocol):
def send(self, request: DeliverySendRequest) -> dict[str, Any]:
"""Send a message and return the provider response."""
class RetryableDeliveryError(RuntimeError):
"""A temporary generation or provider failure."""
class PermanentDeliveryError(RuntimeError):
"""A delivery failure that must not be retried."""
class DeliveryService:
"""Process durable deliveries with fencing and database-driven retry timing."""
def __init__(
self,
db: Session,
*,
generator: DeliveryGenerator,
sender: DeliverySender,
lease_seconds: int = DELIVERY_LEASE_SECONDS,
):
self.db = db
self.generator = generator
self.sender = sender
self.lease_seconds = lease_seconds
def process(
self,
delivery_code: str,
*,
now: datetime | None = None,
worker_id: str = "subscription-delivery",
) -> PushDelivery:
current = _naive_utc(now or utc_now())
existing = self._get(delivery_code)
if existing.status in {
PushDeliveryStatus.SENT,
PushDeliveryStatus.FAILED,
PushDeliveryStatus.SKIPPED,
}:
return existing
lock_owner = f"{worker_id}:{uuid4().hex}"
if not self._claim(existing.id, lock_owner, current):
self.db.rollback()
return self._get(delivery_code)
delivery = self._get(delivery_code)
subscription, owner = self._load_context(delivery.subscription_id)
skip_reason = self._skip_reason(subscription, owner)
if skip_reason is not None:
return self._finish_skipped(delivery, lock_owner, owner, skip_reason)
if (
owner is not None
and is_in_quiet_hours(
current,
owner.timezone,
owner.quiet_hours_start,
owner.quiet_hours_end,
)
):
quiet_end = next_quiet_end(
current,
owner.timezone,
owner.quiet_hours_start,
owner.quiet_hours_end,
)
return self._defer_for_quiet_hours(delivery, lock_owner, quiet_end)
if not self._start_attempt(delivery.id, lock_owner, current):
self.db.rollback()
return self._get(delivery_code)
delivery = self._get(delivery_code)
try:
content = delivery.rendered_content
if content is None:
content = str(
self.generator.generate(
self._generation_request(subscription)
)
).strip()
if not content:
raise PermanentDeliveryError("Delivery generator returned empty content")
self._save_content(delivery.id, lock_owner, content, current)
subscription, owner = self._lock_send_context(delivery.subscription_id)
skip_reason = self._skip_reason(subscription, owner)
if skip_reason is None and owner is not None:
if self._sent_today(owner, current) >= MAX_DAILY_DELIVERIES:
skip_reason = DAILY_DELIVERY_LIMIT_REACHED
if skip_reason is not None:
return self._finish_skipped(
delivery,
lock_owner,
owner,
skip_reason,
)
response = self.sender.send(
DeliverySendRequest(
receive_id=(
owner.open_id
if subscription.target_type == SubscriptionTargetType.USER
else subscription.target_id
),
receive_id_type=(
"open_id"
if subscription.target_type == SubscriptionTargetType.USER
else "chat_id"
),
tenant_key=owner.tenant_key,
text=content,
uuid=delivery.message_uuid,
)
)
self._validate_provider_response(response)
except Exception as exc:
self.db.rollback()
return self._finish_failure(delivery_code, lock_owner, current, exc)
return self._finish_sent(
delivery_code,
lock_owner,
current,
owner,
response,
)
def process_due(
self,
*,
now: datetime | None = None,
limit: int = 100,
worker_id: str = "subscription-delivery",
) -> list[PushDelivery]:
current = _naive_utc(now or utc_now())
stmt = (
select(PushDelivery.code)
.where(
or_(
and_(
PushDelivery.status.in_(
[
PushDeliveryStatus.PENDING,
PushDeliveryStatus.RETRY,
]
),
PushDelivery.next_attempt_at.is_not(None),
PushDelivery.next_attempt_at <= current,
),
and_(
PushDelivery.status == PushDeliveryStatus.PROCESSING,
PushDelivery.locked_until.is_not(None),
PushDelivery.locked_until <= current,
),
)
)
.order_by(PushDelivery.next_attempt_at.asc(), PushDelivery.id.asc())
.limit(bounded_limit(limit))
)
codes = list(self.db.execute(stmt).scalars())
return [
self.process(
code,
now=current,
worker_id=worker_id,
)
for code in codes
]
def _claim(self, delivery_id: int, lock_owner: str, current: datetime) -> bool:
result = self.db.execute(
update(PushDelivery)
.where(
PushDelivery.id == delivery_id,
or_(
and_(
PushDelivery.status.in_(
[
PushDeliveryStatus.PENDING,
PushDeliveryStatus.RETRY,
]
),
PushDelivery.next_attempt_at.is_not(None),
PushDelivery.next_attempt_at <= current,
),
and_(
PushDelivery.status == PushDeliveryStatus.PROCESSING,
PushDelivery.locked_until.is_not(None),
PushDelivery.locked_until <= current,
),
),
or_(
PushDelivery.locked_until.is_(None),
PushDelivery.locked_until <= current,
),
)
.values(
status=PushDeliveryStatus.PROCESSING,
locked_by=lock_owner,
locked_until=current + timedelta(seconds=self.lease_seconds),
)
.execution_options(synchronize_session=False)
)
self.db.commit()
return result.rowcount == 1
def _start_attempt(
self,
delivery_id: int,
lock_owner: str,
current: datetime,
) -> bool:
result = self.db.execute(
update(PushDelivery)
.where(
PushDelivery.id == delivery_id,
PushDelivery.status == PushDeliveryStatus.PROCESSING,
PushDelivery.locked_by == lock_owner,
)
.values(
attempt_count=PushDelivery.attempt_count + 1,
locked_until=current + timedelta(seconds=self.lease_seconds),
)
.execution_options(synchronize_session=False)
)
self.db.commit()
return result.rowcount == 1
def _save_content(
self,
delivery_id: int,
lock_owner: str,
content: str,
current: datetime,
) -> None:
result = self.db.execute(
update(PushDelivery)
.where(
PushDelivery.id == delivery_id,
PushDelivery.status == PushDeliveryStatus.PROCESSING,
PushDelivery.locked_by == lock_owner,
)
.values(
rendered_content=content,
locked_until=current + timedelta(seconds=self.lease_seconds),
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
self.db.rollback()
raise RetryableDeliveryError("Delivery lease was lost")
self.db.commit()
def _finish_sent(
self,
delivery_code: str,
lock_owner: str,
current: datetime,
owner: FeishuUser | None,
response: dict[str, Any],
) -> PushDelivery:
provider_message_id = _provider_message_id(response)
result = self.db.execute(
update(PushDelivery)
.where(
PushDelivery.code == delivery_code,
PushDelivery.status == PushDeliveryStatus.PROCESSING,
PushDelivery.locked_by == lock_owner,
)
.values(
status=PushDeliveryStatus.SENT,
next_attempt_at=None,
provider_message_id=provider_message_id,
last_error=None,
sent_at=current,
locked_by=None,
locked_until=None,
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
self.db.rollback()
return self._get(delivery_code)
record = self._get(delivery_code)
self._audit(
owner,
record,
SubscriptionAuditAction.DELIVERY_SENT,
{"status": PushDeliveryStatus.SENT},
)
self.db.commit()
self.db.refresh(record)
return record
def _finish_failure(
self,
delivery_code: str,
lock_owner: str,
current: datetime,
exc: Exception,
) -> PushDelivery:
record = self._get(delivery_code)
retryable = _is_retryable(exc)
retry_index = record.attempt_count - 1
will_retry = retryable and 0 <= retry_index < len(
DELIVERY_RETRY_DELAYS_SECONDS
)
next_attempt_at = (
current + timedelta(seconds=DELIVERY_RETRY_DELAYS_SECONDS[retry_index])
if will_retry
else None
)
result = self.db.execute(
update(PushDelivery)
.where(
PushDelivery.code == delivery_code,
PushDelivery.status == PushDeliveryStatus.PROCESSING,
PushDelivery.locked_by == lock_owner,
)
.values(
status=(
PushDeliveryStatus.RETRY
if will_retry
else PushDeliveryStatus.FAILED
),
next_attempt_at=next_attempt_at,
last_error=f"{type(exc).__name__}: {exc}"[:2000],
locked_by=None,
locked_until=None,
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
self.db.rollback()
return self._get(delivery_code)
record = self._get(delivery_code)
if not will_retry:
_, owner = self._load_context(record.subscription_id)
self._audit(
owner,
record,
SubscriptionAuditAction.DELIVERY_FAILED,
{
"status": PushDeliveryStatus.FAILED,
"attempt_count": record.attempt_count,
},
)
self.db.commit()
self.db.refresh(record)
return record
def _finish_skipped(
self,
delivery: PushDelivery,
lock_owner: str,
owner: FeishuUser | None,
reason: str,
) -> PushDelivery:
result = self.db.execute(
update(PushDelivery)
.where(
PushDelivery.id == delivery.id,
PushDelivery.status == PushDeliveryStatus.PROCESSING,
PushDelivery.locked_by == lock_owner,
)
.values(
status=PushDeliveryStatus.SKIPPED,
next_attempt_at=None,
last_error=reason,
locked_by=None,
locked_until=None,
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
self.db.rollback()
return self._get(delivery.code)
record = self._get(delivery.code)
self._audit(
owner,
record,
SubscriptionAuditAction.DELIVERY_SKIPPED,
{"status": PushDeliveryStatus.SKIPPED, "reason": reason},
)
self.db.commit()
self.db.refresh(record)
return record
def _defer_for_quiet_hours(
self,
delivery: PushDelivery,
lock_owner: str,
quiet_end: datetime,
) -> PushDelivery:
status_value = (
PushDeliveryStatus.PENDING
if delivery.attempt_count == 0
else PushDeliveryStatus.RETRY
)
result = self.db.execute(
update(PushDelivery)
.where(
PushDelivery.id == delivery.id,
PushDelivery.status == PushDeliveryStatus.PROCESSING,
PushDelivery.locked_by == lock_owner,
)
.values(
status=status_value,
next_attempt_at=quiet_end,
locked_by=None,
locked_until=None,
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
self.db.rollback()
return self._get(delivery.code)
self.db.commit()
return self._get(delivery.code)
def _generation_request(
self,
subscription: PushSubscription,
) -> DeliveryGenerationRequest:
personal = subscription.target_type == SubscriptionTargetType.USER
return DeliveryGenerationRequest(
prompt=subscription.prompt,
owner_id=subscription.owner_id if personal else None,
use_personal_context=personal,
use_company_rules=not personal,
)
def _skip_reason(
self,
subscription: PushSubscription | None,
owner: FeishuUser | None,
) -> str | None:
if subscription is None:
return "Subscription was removed"
if owner is None or owner.status != FeishuUserStatus.ACTIVE:
return "Feishu user is disabled"
if subscription.status not in {
PushSubscriptionStatus.ACTIVE,
PushSubscriptionStatus.COMPLETED,
}:
return "Subscription is not active"
if subscription.target_type == SubscriptionTargetType.USER:
if subscription.target_id != owner.open_id:
return "Private subscription target no longer matches its owner"
return None
if subscription.target_type == SubscriptionTargetType.CHAT:
if owner.role != FeishuUserRole.ADMIN or not subscription.target_id:
return "Group subscription is no longer authorized"
return None
return "Unsupported subscription target"
def _load_context(
self,
subscription_id: int,
) -> tuple[PushSubscription | None, FeishuUser | None]:
subscription = self.db.get(PushSubscription, subscription_id)
owner = (
self.db.get(FeishuUser, subscription.owner_id)
if subscription is not None
else None
)
return subscription, owner
def _lock_send_context(
self,
subscription_id: int,
) -> tuple[PushSubscription | None, FeishuUser | None]:
"""Recheck authorization and serialize the final per-owner send decision."""
subscription = self.db.execute(
select(PushSubscription)
.where(PushSubscription.id == subscription_id)
.with_for_update()
.execution_options(populate_existing=True)
).scalar_one_or_none()
owner = (
self.db.execute(
select(FeishuUser)
.where(FeishuUser.id == subscription.owner_id)
.with_for_update()
.execution_options(populate_existing=True)
).scalar_one_or_none()
if subscription is not None
else None
)
return subscription, owner
def _sent_today(self, owner: FeishuUser, current: datetime) -> int:
zone = ZoneInfo(owner.timezone)
local_now = current.replace(tzinfo=UTC).astimezone(zone)
local_start = datetime.combine(local_now.date(), time.min, tzinfo=zone)
local_end = local_start + timedelta(days=1)
start_utc = local_start.astimezone(UTC).replace(tzinfo=None)
end_utc = local_end.astimezone(UTC).replace(tzinfo=None)
return int(
self.db.scalar(
select(func.count())
.select_from(PushDelivery)
.join(PushSubscription)
.where(
PushSubscription.owner_id == owner.id,
PushDelivery.status == PushDeliveryStatus.SENT,
PushDelivery.sent_at >= start_utc,
PushDelivery.sent_at < end_utc,
)
)
or 0
)
def _get(self, delivery_code: str) -> PushDelivery:
record = self.db.execute(
select(PushDelivery).where(PushDelivery.code == delivery_code)
).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=DELIVERY_NOT_FOUND,
)
return record
def _audit(
self,
owner: FeishuUser | None,
delivery: PushDelivery,
action: str,
response: dict[str, Any],
) -> None:
AuditService(self.db).record(
AuditLogCreate(
actor=owner.code if owner is not None else "subscription-system",
source="subscriptions",
action=action,
target_type="push-delivery",
target_id=delivery.code,
response_payload=response,
)
)
@staticmethod
def _validate_provider_response(response: dict[str, Any]) -> None:
if not isinstance(response, dict):
raise RetryableDeliveryError("Message provider returned an invalid response")
if "code" in response and response.get("code") != 0:
raise RetryableDeliveryError(
f"Message provider returned business code {response.get('code')}"
)
def _provider_message_id(response: dict[str, Any]) -> str | None:
direct = response.get("message_id")
nested = response.get("data")
value = direct or (nested.get("message_id") if isinstance(nested, dict) else None)
return str(value) if value is not None else None
def _is_retryable(exc: Exception) -> bool:
if isinstance(exc, PermanentDeliveryError):
return False
if isinstance(exc, RetryableDeliveryError):
return True
if isinstance(exc, FeishuAPIError):
return exc.retryable
if isinstance(exc, httpx.HTTPStatusError):
code = exc.response.status_code
return code == 429 or code >= 500
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
return True
if isinstance(exc, HTTPException):
return exc.status_code == 429 or exc.status_code >= 500
return True
def _naive_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)

View File

@@ -0,0 +1,498 @@
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.http.pagination import bounded_limit, bounded_offset
from app.core.utils.time import utc_now
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.feishu_users.constants import (
FeishuCapability,
FeishuUserRole,
FeishuUserStatus,
)
from app.modules.feishu_users.models import FeishuUser
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.subscriptions.constants import (
EMPTY_PROMPT,
INVALID_GROUP_TARGET,
INVALID_QUIET_HOURS,
MAX_ACTIVE_SUBSCRIPTIONS,
PushSubscriptionStatus,
SUBSCRIPTION_LIMIT_REACHED,
SUBSCRIPTION_NOT_FOUND,
SubscriptionAuditAction,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services.schedule import (
NormalizedSchedule,
ScheduleParseError,
next_occurrence,
parse_quiet_clock,
parse_schedule,
validate_timezone,
)
class SubscriptionManagementService:
"""Manage subscriptions only through authenticated Feishu principals."""
def __init__(self, db: Session):
self.db = db
def create_private(
self,
principal: FeishuPrincipal,
schedule_expression: str,
prompt: str,
*,
now: datetime | None = None,
) -> tuple[PushSubscription, NormalizedSchedule]:
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
owner = self._active_owner(principal, for_update=True)
return self._create(
owner=owner,
target_type=SubscriptionTargetType.USER,
target_id=owner.open_id,
schedule_expression=schedule_expression,
prompt=prompt,
now=now,
)
def create_group(
self,
principal: FeishuPrincipal,
schedule_expression: str,
prompt: str,
*,
now: datetime | None = None,
) -> tuple[PushSubscription, NormalizedSchedule]:
principal.require_capability(FeishuCapability.GROUP_SUBSCRIPTION)
owner = self._active_owner(principal, for_update=True)
if (
owner.role != FeishuUserRole.ADMIN
or not principal.chat_id
or principal.chat_type not in {"group", "group_chat"}
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=INVALID_GROUP_TARGET,
)
return self._create(
owner=owner,
target_type=SubscriptionTargetType.CHAT,
target_id=principal.chat_id,
schedule_expression=schedule_expression,
prompt=prompt,
now=now,
)
def list_for_owner(self, principal: FeishuPrincipal) -> list[PushSubscription]:
principal.require_active()
return list(
self.db.execute(
select(PushSubscription)
.where(PushSubscription.owner_id == principal.owner_id)
.order_by(PushSubscription.id.desc())
).scalars()
)
def latest_deliveries_for_owner(
self,
principal: FeishuPrincipal,
) -> dict[int, PushDelivery]:
"""Return at most one latest delivery per owner-scoped subscription."""
principal.require_active()
latest_ids = (
select(func.max(PushDelivery.id))
.join(PushSubscription)
.where(PushSubscription.owner_id == principal.owner_id)
.group_by(PushDelivery.subscription_id)
)
records = self.db.execute(
select(PushDelivery).where(PushDelivery.id.in_(latest_ids))
).scalars()
return {record.subscription_id: record for record in records}
def pause(self, principal: FeishuPrincipal, code: str) -> PushSubscription:
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
record = self._owned_subscription(principal.owner_id, code, for_update=True)
if record.status == PushSubscriptionStatus.ACTIVE:
record.status = PushSubscriptionStatus.PAUSED
self._audit(principal, SubscriptionAuditAction.PAUSE, record)
self.db.commit()
self.db.refresh(record)
return record
def resume(
self,
principal: FeishuPrincipal,
code: str,
*,
now: datetime | None = None,
) -> PushSubscription:
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
self._active_owner(principal, for_update=True)
record = self._owned_subscription(principal.owner_id, code, for_update=True)
if record.status != PushSubscriptionStatus.PAUSED:
return record
self._ensure_active_capacity(principal.owner_id)
current = _naive_utc(now or utc_now())
if record.next_run_at is None or record.next_run_at <= current:
next_run = next_occurrence(
record.schedule_type,
record.schedule_config,
record.timezone,
after=current,
)
if next_run is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Expired one-time subscriptions cannot be resumed",
)
record.next_run_at = next_run
record.status = PushSubscriptionStatus.ACTIVE
self._audit(principal, SubscriptionAuditAction.RESUME, record)
self.db.commit()
self.db.refresh(record)
return record
def cancel(self, principal: FeishuPrincipal, code: str) -> PushSubscription:
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
record = self._owned_subscription(principal.owner_id, code, for_update=True)
if record.status != PushSubscriptionStatus.CANCELLED:
record.status = PushSubscriptionStatus.CANCELLED
record.next_run_at = None
self._audit(principal, SubscriptionAuditAction.CANCEL, record)
self.db.commit()
self.db.refresh(record)
return record
def set_timezone(
self,
principal: FeishuPrincipal,
timezone_name: str,
*,
now: datetime | None = None,
) -> FeishuUser:
principal.require_active()
try:
validate_timezone(timezone_name)
except ScheduleParseError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
owner = self._active_owner(principal, for_update=True)
owner.timezone = timezone_name
current = _naive_utc(now or utc_now())
subscriptions = list(
self.db.execute(
select(PushSubscription).where(
PushSubscription.owner_id == owner.id,
PushSubscription.status.in_(
[
PushSubscriptionStatus.ACTIVE,
PushSubscriptionStatus.PAUSED,
]
),
)
).scalars()
)
for subscription in subscriptions:
subscription.timezone = timezone_name
if (
subscription.status == PushSubscriptionStatus.ACTIVE
and subscription.schedule_type
not in {
SubscriptionScheduleType.ONCE,
SubscriptionScheduleType.INTERVAL,
}
):
subscription.next_run_at = next_occurrence(
subscription.schedule_type,
subscription.schedule_config,
timezone_name,
after=current,
)
self._audit_user(
principal,
SubscriptionAuditAction.UPDATE_TIMEZONE,
{"timezone": timezone_name},
)
self.db.commit()
self.db.refresh(owner)
return owner
def set_quiet_hours(
self,
principal: FeishuPrincipal,
start: str,
end: str,
) -> FeishuUser:
principal.require_active()
try:
quiet_start = parse_quiet_clock(start)
quiet_end = parse_quiet_clock(end)
except ScheduleParseError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
if quiet_start == quiet_end:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=INVALID_QUIET_HOURS,
)
owner = self._active_owner(principal, for_update=True)
owner.quiet_hours_start = quiet_start
owner.quiet_hours_end = quiet_end
self._audit_user(
principal,
SubscriptionAuditAction.UPDATE_QUIET_HOURS,
{"enabled": True},
)
self.db.commit()
self.db.refresh(owner)
return owner
def clear_quiet_hours(self, principal: FeishuPrincipal) -> FeishuUser:
principal.require_active()
owner = self._active_owner(principal, for_update=True)
owner.quiet_hours_start = None
owner.quiet_hours_end = None
self._audit_user(
principal,
SubscriptionAuditAction.UPDATE_QUIET_HOURS,
{"enabled": False},
)
self.db.commit()
self.db.refresh(owner)
return owner
def list_all(
self,
*,
status_filter: str | None = None,
owner_id: int | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[int, list[PushSubscription]]:
stmt = select(PushSubscription)
count_stmt = select(func.count()).select_from(PushSubscription)
if status_filter:
stmt = stmt.where(PushSubscription.status == status_filter)
count_stmt = count_stmt.where(PushSubscription.status == status_filter)
if owner_id is not None:
stmt = stmt.where(PushSubscription.owner_id == owner_id)
count_stmt = count_stmt.where(PushSubscription.owner_id == owner_id)
stmt = (
stmt.order_by(PushSubscription.id.desc())
.limit(bounded_limit(limit))
.offset(bounded_offset(offset))
)
total = int(self.db.scalar(count_stmt) or 0)
return total, list(self.db.execute(stmt).scalars())
def list_deliveries(
self,
*,
status_filter: str | None = None,
subscription_code: str | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[int, list[PushDelivery]]:
stmt = select(PushDelivery).join(PushSubscription)
count_stmt = (
select(func.count())
.select_from(PushDelivery)
.join(PushSubscription)
)
if status_filter:
stmt = stmt.where(PushDelivery.status == status_filter)
count_stmt = count_stmt.where(PushDelivery.status == status_filter)
if subscription_code:
stmt = stmt.where(PushSubscription.code == subscription_code)
count_stmt = count_stmt.where(PushSubscription.code == subscription_code)
stmt = (
stmt.order_by(PushDelivery.id.desc())
.limit(bounded_limit(limit))
.offset(bounded_offset(offset))
)
total = int(self.db.scalar(count_stmt) or 0)
return total, list(self.db.execute(stmt).scalars())
def _create(
self,
*,
owner: FeishuUser,
target_type: str,
target_id: str,
schedule_expression: str,
prompt: str,
now: datetime | None,
) -> tuple[PushSubscription, NormalizedSchedule]:
clean_prompt = str(prompt or "").strip()
if not clean_prompt:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=EMPTY_PROMPT,
)
self._ensure_active_capacity(owner.id)
try:
schedule = parse_schedule(
schedule_expression,
owner.timezone,
now=now,
)
except ScheduleParseError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
record = PushSubscription(
code=f"SUB-{uuid4().hex.upper()}",
owner_id=owner.id,
target_type=target_type,
target_id=target_id,
prompt=clean_prompt,
schedule_type=schedule.schedule_type,
schedule_config=schedule.schedule_config,
timezone=schedule.timezone,
next_run_at=schedule.next_run_at,
status=PushSubscriptionStatus.ACTIVE,
consented_at=_naive_utc(now or utc_now()),
)
self.db.add(record)
self.db.flush()
self._audit_values(
actor=owner.code,
action=SubscriptionAuditAction.CREATE,
target_id=record.code,
response={
"target_type": target_type,
"schedule_type": schedule.schedule_type,
},
)
self.db.commit()
self.db.refresh(record)
return record, schedule
def _ensure_active_capacity(self, owner_id: int) -> None:
count = int(
self.db.scalar(
select(func.count())
.select_from(PushSubscription)
.where(
PushSubscription.owner_id == owner_id,
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
)
)
or 0
)
if count >= MAX_ACTIVE_SUBSCRIPTIONS:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=SUBSCRIPTION_LIMIT_REACHED,
)
def _active_owner(
self,
principal: FeishuPrincipal,
*,
for_update: bool = False,
) -> FeishuUser:
stmt = select(FeishuUser).where(FeishuUser.id == principal.owner_id)
if for_update:
stmt = stmt.with_for_update()
owner = self.db.execute(stmt).scalar_one_or_none()
if owner is None or owner.status != FeishuUserStatus.ACTIVE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feishu user is disabled",
)
if owner.tenant_key != principal.tenant_key or owner.open_id != principal.open_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feishu identity mismatch",
)
return owner
def _owned_subscription(
self,
owner_id: int,
code: str,
*,
for_update: bool = False,
) -> PushSubscription:
stmt = select(PushSubscription).where(
PushSubscription.owner_id == owner_id,
PushSubscription.code == code,
)
if for_update:
stmt = stmt.with_for_update()
record = self.db.execute(stmt).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=SUBSCRIPTION_NOT_FOUND,
)
return record
def _audit(
self,
principal: FeishuPrincipal,
action: str,
record: PushSubscription,
) -> None:
self._audit_values(
actor=principal.user_code,
action=action,
target_id=record.code,
response={"status": record.status},
)
def _audit_user(
self,
principal: FeishuPrincipal,
action: str,
response: dict[str, Any],
) -> None:
self._audit_values(
actor=principal.user_code,
action=action,
target_id=principal.user_code,
response=response,
)
def _audit_values(
self,
*,
actor: str,
action: str,
target_id: str,
response: dict[str, Any],
) -> None:
AuditService(self.db).record(
AuditLogCreate(
actor=actor,
source="subscriptions",
action=action,
target_type="subscription",
target_id=target_id,
response_payload=response,
)
)
def _naive_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)

View File

@@ -0,0 +1,309 @@
from datetime import UTC, datetime, time, timedelta
from hashlib import sha256
from uuid import NAMESPACE_URL, uuid4, uuid5
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.http.pagination import bounded_limit
from app.core.utils.time import utc_now
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
from app.modules.feishu_users.models import FeishuUser
from app.modules.subscriptions.constants import (
DAILY_DELIVERY_LIMIT_REACHED,
MAX_DAILY_DELIVERIES,
PushDeliveryStatus,
PushSubscriptionStatus,
SUBSCRIPTION_LEASE_SECONDS,
SubscriptionAuditAction,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services.schedule import (
is_in_quiet_hours,
next_occurrence,
next_quiet_end,
validate_timezone,
)
class SubscriptionScanner:
"""Claim due plans and materialize one durable delivery per schedule window."""
def __init__(self, db: Session, *, lease_seconds: int = SUBSCRIPTION_LEASE_SECONDS):
self.db = db
self.lease_seconds = lease_seconds
def scan_due(
self,
*,
now: datetime | None = None,
limit: int = 100,
worker_id: str = "subscription-scanner",
) -> list[PushDelivery]:
current = _naive_utc(now or utc_now())
claims = self._claim_due_subscriptions(
current=current,
limit=limit,
worker_id=worker_id,
)
deliveries: list[PushDelivery] = []
for subscription_id, lock_owner in claims:
try:
delivery = self._materialize_delivery(
subscription_id=subscription_id,
lock_owner=lock_owner,
current=current,
)
except Exception:
self.db.rollback()
self._release_claim(subscription_id, lock_owner)
raise
if delivery is not None:
deliveries.append(delivery)
return deliveries
def _claim_due_subscriptions(
self,
*,
current: datetime,
limit: int,
worker_id: str,
) -> list[tuple[int, str]]:
stmt = (
select(PushSubscription.id)
.where(
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
PushSubscription.next_run_at.is_not(None),
PushSubscription.next_run_at <= current,
or_(
PushSubscription.locked_until.is_(None),
PushSubscription.locked_until <= current,
),
)
.order_by(PushSubscription.next_run_at.asc(), PushSubscription.id.asc())
.limit(bounded_limit(limit))
)
if self.db.get_bind().dialect.name == "postgresql":
stmt = stmt.with_for_update(skip_locked=True)
candidate_ids = list(self.db.execute(stmt).scalars())
claims: list[tuple[int, str]] = []
locked_until = current + timedelta(seconds=self.lease_seconds)
for subscription_id in candidate_ids:
lock_owner = f"{worker_id}:{uuid4().hex}"
result = self.db.execute(
update(PushSubscription)
.where(
PushSubscription.id == subscription_id,
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
PushSubscription.next_run_at.is_not(None),
PushSubscription.next_run_at <= current,
or_(
PushSubscription.locked_until.is_(None),
PushSubscription.locked_until <= current,
),
)
.values(locked_by=lock_owner, locked_until=locked_until)
.execution_options(synchronize_session=False)
)
if result.rowcount == 1:
claims.append((subscription_id, lock_owner))
self.db.commit()
return claims
def _materialize_delivery(
self,
*,
subscription_id: int,
lock_owner: str,
current: datetime,
) -> PushDelivery | None:
subscription = self.db.execute(
select(PushSubscription)
.where(
PushSubscription.id == subscription_id,
PushSubscription.locked_by == lock_owner,
)
.with_for_update()
).scalar_one_or_none()
if subscription is None:
self.db.rollback()
return None
if (
subscription.status != PushSubscriptionStatus.ACTIVE
or subscription.next_run_at is None
):
subscription.locked_by = None
subscription.locked_until = None
self.db.commit()
return None
owner = self.db.execute(
select(FeishuUser)
.where(FeishuUser.id == subscription.owner_id)
.with_for_update()
).scalar_one_or_none()
scheduled_for = subscription.next_run_at
skip_reason = self._delivery_skip_reason(subscription, owner, current)
next_attempt_at = current
if (
skip_reason is None
and owner is not None
and is_in_quiet_hours(
current,
owner.timezone,
owner.quiet_hours_start,
owner.quiet_hours_end,
)
):
next_attempt_at = next_quiet_end(
current,
owner.timezone,
owner.quiet_hours_start,
owner.quiet_hours_end,
)
idempotency_key = _delivery_key(subscription.id, scheduled_for)
message_uuid = str(uuid5(NAMESPACE_URL, f"company-ai-platform:{idempotency_key}"))
delivery = PushDelivery(
code=f"DEL-{uuid4().hex.upper()}",
subscription_id=subscription.id,
scheduled_for=scheduled_for,
idempotency_key=idempotency_key,
message_uuid=message_uuid,
status=(
PushDeliveryStatus.SKIPPED
if skip_reason is not None
else PushDeliveryStatus.PENDING
),
next_attempt_at=None if skip_reason is not None else next_attempt_at,
last_error=skip_reason,
created_at=current,
updated_at=current,
)
try:
with self.db.begin_nested():
self.db.add(delivery)
self.db.flush()
except IntegrityError:
delivery = self.db.execute(
select(PushDelivery).where(
PushDelivery.idempotency_key == idempotency_key
)
).scalar_one()
subscription.last_run_at = scheduled_for
if subscription.schedule_type == SubscriptionScheduleType.ONCE:
subscription.status = PushSubscriptionStatus.COMPLETED
subscription.next_run_at = None
else:
subscription.next_run_at = next_occurrence(
subscription.schedule_type,
subscription.schedule_config,
subscription.timezone,
after=current,
)
subscription.locked_by = None
subscription.locked_until = None
if skip_reason is not None:
self._audit_skipped(owner, delivery, skip_reason)
self.db.commit()
self.db.refresh(delivery)
return delivery
def _delivery_skip_reason(
self,
subscription: PushSubscription,
owner: FeishuUser | None,
current: datetime,
) -> str | None:
if owner is None or owner.status != FeishuUserStatus.ACTIVE:
return "Feishu user is disabled"
if (
subscription.target_type == SubscriptionTargetType.USER
and subscription.target_id != owner.open_id
):
return "Private subscription target no longer matches its owner"
if subscription.target_type == SubscriptionTargetType.CHAT and (
owner.role != FeishuUserRole.ADMIN or not subscription.target_id
):
return "Group subscription owner is no longer an administrator"
if subscription.target_type not in {
SubscriptionTargetType.USER,
SubscriptionTargetType.CHAT,
}:
return "Unsupported subscription target"
if self._daily_delivery_count(owner, current) >= MAX_DAILY_DELIVERIES:
return DAILY_DELIVERY_LIMIT_REACHED
return None
def _daily_delivery_count(self, owner: FeishuUser, current: datetime) -> int:
zone = validate_timezone(owner.timezone)
local_now = current.replace(tzinfo=UTC).astimezone(zone)
local_start = datetime.combine(local_now.date(), time.min, tzinfo=zone)
local_end = local_start + timedelta(days=1)
start_utc = local_start.astimezone(UTC).replace(tzinfo=None)
end_utc = local_end.astimezone(UTC).replace(tzinfo=None)
return int(
self.db.scalar(
select(func.count())
.select_from(PushDelivery)
.join(PushSubscription)
.where(
PushSubscription.owner_id == owner.id,
PushDelivery.created_at >= start_utc,
PushDelivery.created_at < end_utc,
PushDelivery.status.not_in(
[
PushDeliveryStatus.FAILED,
PushDeliveryStatus.SKIPPED,
]
),
)
)
or 0
)
def _audit_skipped(
self,
owner: FeishuUser | None,
delivery: PushDelivery,
reason: str,
) -> None:
AuditService(self.db).record(
AuditLogCreate(
actor=owner.code if owner is not None else "subscription-system",
source="subscriptions",
action=SubscriptionAuditAction.DELIVERY_SKIPPED,
target_type="push-delivery",
target_id=delivery.code,
response_payload={"status": PushDeliveryStatus.SKIPPED, "reason": reason},
)
)
def _release_claim(self, subscription_id: int, lock_owner: str) -> None:
self.db.execute(
update(PushSubscription)
.where(
PushSubscription.id == subscription_id,
PushSubscription.locked_by == lock_owner,
)
.values(locked_by=None, locked_until=None)
.execution_options(synchronize_session=False)
)
self.db.commit()
def _delivery_key(subscription_id: int, scheduled_for: datetime) -> str:
material = f"{subscription_id}:{_naive_utc(scheduled_for).isoformat(timespec='microseconds')}"
return sha256(material.encode("utf-8")).hexdigest()
def _naive_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)

View File

@@ -0,0 +1,462 @@
import re
from calendar import monthrange
from dataclasses import dataclass
from datetime import UTC, date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from app.modules.subscriptions.constants import (
INVALID_SCHEDULE,
INVALID_TIMEZONE,
MIN_INTERVAL_MINUTES,
SubscriptionScheduleType,
)
_WEEKDAYS = {
"": 0,
"": 1,
"": 2,
"": 3,
"": 4,
"": 5,
"": 6,
"": 6,
}
_WEEKDAY_NAMES = ("", "", "", "", "", "", "")
_INTERVAL_PATTERN = re.compile(r"每隔\s*(?P<value>\d+)\s*(?P<unit>分钟|小时)")
_DAILY_PATTERN = re.compile(r"每天\s*(?P<clock>.+)")
_WEEKDAY_PATTERN = re.compile(r"(?:每个)?工作日\s*(?P<clock>.+)")
_WEEKLY_PATTERN = re.compile(r"每周(?P<weekday>[一二三四五六日天])\s*(?P<clock>.+)")
_MONTHLY_PATTERN = re.compile(
r"每月\s*(?P<day>\d{1,2})\s*(?:号|日)\s*(?P<clock>.+)"
)
_RELATIVE_PATTERN = re.compile(r"(?P<day>今天|明天)\s*(?P<clock>.+)")
_ISO_DATE_PATTERN = re.compile(
r"(?P<year>\d{4})[-/](?P<month>\d{1,2})[-/](?P<day>\d{1,2})"
r"\s+(?P<clock>.+)"
)
_CHINESE_DATE_PATTERN = re.compile(
r"(?P<year>\d{4})年(?P<month>\d{1,2})月(?P<day>\d{1,2})[日号]"
r"\s*(?P<clock>.+)"
)
_COLON_CLOCK_PATTERN = re.compile(r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})")
_CHINESE_CLOCK_PATTERN = re.compile(
r"(?P<hour>\d{1,2})点(?:(?P<half>半)|(?P<minute>\d{1,2})分?)?"
)
class ScheduleParseError(ValueError):
"""Raised when a controlled schedule expression cannot be normalized."""
@dataclass(frozen=True, slots=True)
class NormalizedSchedule:
schedule_type: str
schedule_config: dict[str, Any]
timezone: str
next_run_at: datetime
display: str
def parse_schedule(
expression: str,
timezone_name: str,
*,
now: datetime | None = None,
) -> NormalizedSchedule:
"""Parse the supported Chinese schedule grammar into a UTC plan."""
text = _normalize_expression(expression)
zone = validate_timezone(timezone_name)
now_utc = _as_utc(now)
local_now = now_utc.astimezone(zone)
match = _INTERVAL_PATTERN.fullmatch(text)
if match:
value = int(match.group("value"))
minutes = value * (60 if match.group("unit") == "小时" else 1)
if minutes < MIN_INTERVAL_MINUTES:
raise ScheduleParseError(f"订阅间隔不得短于 {MIN_INTERVAL_MINUTES} 分钟")
try:
next_run = now_utc + timedelta(minutes=minutes)
except OverflowError as exc:
raise ScheduleParseError("订阅间隔过大") from exc
config = {
"minutes": minutes,
"anchor_at": _to_naive_utc(next_run).isoformat(),
}
display_value = (
f"每隔 {value} 小时" if match.group("unit") == "小时" else f"每隔 {value} 分钟"
)
return NormalizedSchedule(
schedule_type=SubscriptionScheduleType.INTERVAL,
schedule_config=config,
timezone=timezone_name,
next_run_at=_to_naive_utc(next_run),
display=display_value,
)
match = _DAILY_PATTERN.fullmatch(text)
if match:
hour, minute = _parse_clock(match.group("clock"))
config = {"hour": hour, "minute": minute}
return _recurring_schedule(
SubscriptionScheduleType.DAILY,
config,
timezone_name,
now_utc,
f"每天 {hour:02d}:{minute:02d}",
)
match = _WEEKDAY_PATTERN.fullmatch(text)
if match:
hour, minute = _parse_clock(match.group("clock"))
config = {"hour": hour, "minute": minute}
return _recurring_schedule(
SubscriptionScheduleType.WEEKDAY,
config,
timezone_name,
now_utc,
f"工作日 {hour:02d}:{minute:02d}",
)
match = _WEEKLY_PATTERN.fullmatch(text)
if match:
hour, minute = _parse_clock(match.group("clock"))
weekday = _WEEKDAYS[match.group("weekday")]
config = {"weekday": weekday, "hour": hour, "minute": minute}
return _recurring_schedule(
SubscriptionScheduleType.WEEKLY,
config,
timezone_name,
now_utc,
f"每周{_WEEKDAY_NAMES[weekday]} {hour:02d}:{minute:02d}",
)
match = _MONTHLY_PATTERN.fullmatch(text)
if match:
day = int(match.group("day"))
if not 1 <= day <= 31:
raise ScheduleParseError("每月日期必须在 1 到 31 之间")
hour, minute = _parse_clock(match.group("clock"))
config = {"day": day, "hour": hour, "minute": minute}
return _recurring_schedule(
SubscriptionScheduleType.MONTHLY,
config,
timezone_name,
now_utc,
f"每月 {day}{hour:02d}:{minute:02d}",
)
match = _RELATIVE_PATTERN.fullmatch(text)
if match:
hour, minute = _parse_clock(match.group("clock"))
offset = 1 if match.group("day") == "明天" else 0
target_date = local_now.date() + timedelta(days=offset)
return _once_schedule(
target_date,
hour,
minute,
timezone_name,
now_utc,
f"{match.group('day')} {hour:02d}:{minute:02d}",
)
match = _ISO_DATE_PATTERN.fullmatch(text) or _CHINESE_DATE_PATTERN.fullmatch(text)
if match:
try:
target_date = date(
int(match.group("year")),
int(match.group("month")),
int(match.group("day")),
)
except ValueError as exc:
raise ScheduleParseError("日期不存在") from exc
hour, minute = _parse_clock(match.group("clock"))
return _once_schedule(
target_date,
hour,
minute,
timezone_name,
now_utc,
f"{target_date.isoformat()} {hour:02d}:{minute:02d}",
)
raise ScheduleParseError(
f"{INVALID_SCHEDULE}。示例:每天 09:00、每周一 18:00、每隔 30 分钟"
)
def next_occurrence(
schedule_type: str,
schedule_config: dict[str, Any],
timezone_name: str,
*,
after: datetime,
) -> datetime | None:
"""Return the first UTC occurrence strictly after ``after``."""
zone = validate_timezone(timezone_name)
after_utc = _as_utc(after)
plan_type = SubscriptionScheduleType(schedule_type)
if plan_type == SubscriptionScheduleType.ONCE:
run_at = _parse_stored_utc(schedule_config["run_at"])
return _to_naive_utc(run_at) if run_at > after_utc else None
if plan_type == SubscriptionScheduleType.INTERVAL:
interval = timedelta(minutes=int(schedule_config["minutes"]))
anchor = _parse_stored_utc(schedule_config["anchor_at"])
if anchor > after_utc:
return _to_naive_utc(anchor)
elapsed = after_utc - anchor
steps = elapsed // interval + 1
return _to_naive_utc(anchor + interval * steps)
hour = int(schedule_config["hour"])
minute = int(schedule_config["minute"])
local_after = after_utc.astimezone(zone)
if plan_type == SubscriptionScheduleType.DAILY:
return _next_daily(local_after, hour, minute, zone)
if plan_type == SubscriptionScheduleType.WEEKDAY:
return _next_weekday(local_after, hour, minute, zone)
if plan_type == SubscriptionScheduleType.WEEKLY:
weekday = int(schedule_config["weekday"])
return _next_weekly(local_after, weekday, hour, minute, zone)
if plan_type == SubscriptionScheduleType.MONTHLY:
day = int(schedule_config["day"])
return _next_monthly(local_after, day, hour, minute, zone)
raise ScheduleParseError(INVALID_SCHEDULE)
def is_in_quiet_hours(
current: datetime,
timezone_name: str,
quiet_start: time | str | None,
quiet_end: time | str | None,
) -> bool:
if quiet_start is None or quiet_end is None:
return False
start = _coerce_time(quiet_start)
end = _coerce_time(quiet_end)
if start == end:
return False
local_time = _as_utc(current).astimezone(validate_timezone(timezone_name)).time()
local_time = local_time.replace(tzinfo=None)
if start < end:
return start <= local_time < end
return local_time >= start or local_time < end
def next_quiet_end(
current: datetime,
timezone_name: str,
quiet_start: time | str,
quiet_end: time | str,
) -> datetime:
"""Return quiet-window end as a naive UTC timestamp."""
zone = validate_timezone(timezone_name)
now_local = _as_utc(current).astimezone(zone)
start = _coerce_time(quiet_start)
end = _coerce_time(quiet_end)
end_date = now_local.date()
if start > end and now_local.time().replace(tzinfo=None) >= start:
end_date += timedelta(days=1)
candidate = _local_candidate(end_date, end.hour, end.minute, zone)
if candidate is None:
candidate = _first_valid_local_after(end_date, end.hour, end.minute, zone)
return _to_naive_utc(candidate)
def validate_timezone(timezone_name: str) -> ZoneInfo:
try:
return ZoneInfo(timezone_name)
except (ZoneInfoNotFoundError, ValueError, TypeError) as exc:
raise ScheduleParseError(INVALID_TIMEZONE) from exc
def parse_quiet_clock(value: str) -> time:
hour, minute = _parse_clock(_normalize_expression(value))
return time(hour=hour, minute=minute)
def _recurring_schedule(
schedule_type: str,
config: dict[str, Any],
timezone_name: str,
now_utc: datetime,
display: str,
) -> NormalizedSchedule:
next_run = next_occurrence(
schedule_type,
config,
timezone_name,
after=now_utc,
)
if next_run is None:
raise ScheduleParseError(INVALID_SCHEDULE)
return NormalizedSchedule(
schedule_type=schedule_type,
schedule_config=config,
timezone=timezone_name,
next_run_at=next_run,
display=display,
)
def _once_schedule(
target_date: date,
hour: int,
minute: int,
timezone_name: str,
now_utc: datetime,
display: str,
) -> NormalizedSchedule:
zone = validate_timezone(timezone_name)
target = _local_candidate(target_date, hour, minute, zone)
if target is None:
raise ScheduleParseError("该本地时间不存在")
if target <= now_utc:
raise ScheduleParseError("执行时间必须晚于当前时间")
run_at = _to_naive_utc(target)
return NormalizedSchedule(
schedule_type=SubscriptionScheduleType.ONCE,
schedule_config={"run_at": run_at.isoformat()},
timezone=timezone_name,
next_run_at=run_at,
display=display,
)
def _next_daily(local_after: datetime, hour: int, minute: int, zone: ZoneInfo) -> datetime:
for offset in range(0, 370):
candidate = _local_candidate(local_after.date() + timedelta(days=offset), hour, minute, zone)
if candidate is not None and candidate > local_after.astimezone(UTC):
return _to_naive_utc(candidate)
raise ScheduleParseError(INVALID_SCHEDULE)
def _next_weekday(local_after: datetime, hour: int, minute: int, zone: ZoneInfo) -> datetime:
for offset in range(0, 14):
target_date = local_after.date() + timedelta(days=offset)
if target_date.weekday() >= 5:
continue
candidate = _local_candidate(target_date, hour, minute, zone)
if candidate is not None and candidate > local_after.astimezone(UTC):
return _to_naive_utc(candidate)
raise ScheduleParseError(INVALID_SCHEDULE)
def _next_weekly(
local_after: datetime,
weekday: int,
hour: int,
minute: int,
zone: ZoneInfo,
) -> datetime:
offset = (weekday - local_after.weekday()) % 7
for weeks in range(0, 3):
target_date = local_after.date() + timedelta(days=offset + weeks * 7)
candidate = _local_candidate(target_date, hour, minute, zone)
if candidate is not None and candidate > local_after.astimezone(UTC):
return _to_naive_utc(candidate)
raise ScheduleParseError(INVALID_SCHEDULE)
def _next_monthly(
local_after: datetime,
day: int,
hour: int,
minute: int,
zone: ZoneInfo,
) -> datetime:
year = local_after.year
month = local_after.month
for _ in range(0, 240):
if day <= monthrange(year, month)[1]:
candidate = _local_candidate(date(year, month, day), hour, minute, zone)
if candidate is not None and candidate > local_after.astimezone(UTC):
return _to_naive_utc(candidate)
month += 1
if month == 13:
year += 1
month = 1
raise ScheduleParseError(INVALID_SCHEDULE)
def _normalize_expression(expression: str) -> str:
text = re.sub(r"\s+", " ", str(expression or "").strip()).replace("", ":")
if not text:
raise ScheduleParseError(INVALID_SCHEDULE)
return text
def _parse_clock(value: str) -> tuple[int, int]:
text = value.strip().replace("", ":")
match = _COLON_CLOCK_PATTERN.fullmatch(text)
if match:
hour = int(match.group("hour"))
minute = int(match.group("minute"))
else:
match = _CHINESE_CLOCK_PATTERN.fullmatch(text)
if not match:
raise ScheduleParseError("时间必须使用 HH:MM 或 H点M分")
hour = int(match.group("hour"))
minute = 30 if match.group("half") else int(match.group("minute") or 0)
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
raise ScheduleParseError("时间超出有效范围")
return hour, minute
def _local_candidate(
target_date: date,
hour: int,
minute: int,
zone: ZoneInfo,
) -> datetime | None:
naive = datetime.combine(target_date, time(hour=hour, minute=minute))
aware = naive.replace(tzinfo=zone)
roundtrip = aware.astimezone(UTC).astimezone(zone).replace(tzinfo=None)
if roundtrip != naive:
return None
return aware.astimezone(UTC)
def _first_valid_local_after(
target_date: date,
hour: int,
minute: int,
zone: ZoneInfo,
) -> datetime:
base = datetime.combine(target_date, time(hour=hour, minute=minute))
for offset in range(0, 181):
candidate = base + timedelta(minutes=offset)
aware = _local_candidate(candidate.date(), candidate.hour, candidate.minute, zone)
if aware is not None:
return aware
raise ScheduleParseError("安静时段结束时间无效")
def _coerce_time(value: time | str) -> time:
if isinstance(value, time):
return value.replace(tzinfo=None, second=0, microsecond=0)
return parse_quiet_clock(value)
def _as_utc(value: datetime | None) -> datetime:
if value is None:
return datetime.now(UTC)
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _to_naive_utc(value: datetime) -> datetime:
return _as_utc(value).replace(tzinfo=None)
def _parse_stored_utc(value: str | datetime) -> datetime:
parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
return _as_utc(parsed)