```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
22
app/modules/subscriptions/__init__.py
Normal file
22
app/modules/subscriptions/__init__.py
Normal 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",
|
||||
]
|
||||
65
app/modules/subscriptions/constants.py
Normal file
65
app/modules/subscriptions/constants.py
Normal 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"
|
||||
|
||||
122
app/modules/subscriptions/models.py
Normal file
122
app/modules/subscriptions/models.py
Normal 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")
|
||||
|
||||
49
app/modules/subscriptions/routes.py
Normal file
49
app/modules/subscriptions/routes.py
Normal 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],
|
||||
}
|
||||
55
app/modules/subscriptions/schemas.py
Normal file
55
app/modules/subscriptions/schemas.py
Normal 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
|
||||
|
||||
39
app/modules/subscriptions/services/__init__.py
Normal file
39
app/modules/subscriptions/services/__init__.py
Normal 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",
|
||||
]
|
||||
645
app/modules/subscriptions/services/delivery.py
Normal file
645
app/modules/subscriptions/services/delivery.py
Normal 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)
|
||||
498
app/modules/subscriptions/services/management.py
Normal file
498
app/modules/subscriptions/services/management.py
Normal 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)
|
||||
309
app/modules/subscriptions/services/scanner.py
Normal file
309
app/modules/subscriptions/services/scanner.py
Normal 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)
|
||||
462
app/modules/subscriptions/services/schedule.py
Normal file
462
app/modules/subscriptions/services/schedule.py
Normal 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)
|
||||
Reference in New Issue
Block a user