```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -9,6 +9,9 @@ from app.core.utils.time import utc_now
|
||||
|
||||
class SystemHeartbeat(Base):
|
||||
__tablename__ = "system_heartbeats"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("component", "instance_id", name="uq_system_heartbeat_component_instance"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
component: Mapped[str] = mapped_column(String(128), index=True)
|
||||
|
||||
@@ -32,4 +32,6 @@ def ready(
|
||||
def metrics(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return ObservabilityService(db).metrics()
|
||||
result = ObservabilityService(db).metrics()
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import and_, func, or_, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -18,6 +21,10 @@ from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.events.constants import EventStatus
|
||||
from app.modules.events.services import EventService
|
||||
from app.modules.feishu.app_tickets import FeishuAppTicketService
|
||||
from app.modules.feishu.constants import FeishuAppType
|
||||
from app.modules.feishu_users.constants import FeishuUserStatus
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.observability.constants import (
|
||||
HeartbeatStatus,
|
||||
ObservabilityKey,
|
||||
@@ -27,6 +34,11 @@ from app.modules.observability.constants import (
|
||||
from app.modules.observability.models import SystemHeartbeat
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
from app.modules.workflows.service import WorkflowService
|
||||
from app.modules.subscriptions.constants import (
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
|
||||
|
||||
class ObservabilityService:
|
||||
@@ -40,31 +52,42 @@ class ObservabilityService:
|
||||
|
||||
def ready(self) -> dict[str, Any]:
|
||||
checks = {
|
||||
ObservabilityKey.DATABASE: self._database_check(),
|
||||
ObservabilityKey.REDIS: self._redis_check(),
|
||||
ObservabilityKey.EVENTS: self._events_check(),
|
||||
ObservabilityKey.WORKFLOWS: self._workflows_check(),
|
||||
ObservabilityKey.HEARTBEATS: self._heartbeats_check(),
|
||||
}
|
||||
degraded = any(
|
||||
item[ObservabilityKey.STATUS]
|
||||
in {ObservabilityStatus.DEGRADED, ObservabilityStatus.ERROR}
|
||||
for item in checks.values()
|
||||
)
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.DEGRADED if degraded else ObservabilityStatus.OK
|
||||
ObservabilityKey.DATABASE: self._safe_call(self._database_check),
|
||||
ObservabilityKey.REDIS: self._safe_call(self._redis_check),
|
||||
ObservabilityKey.EVENTS: self._safe_call(self._events_check),
|
||||
ObservabilityKey.WORKFLOWS: self._safe_call(self._workflows_check),
|
||||
ObservabilityKey.HEARTBEATS: self._safe_call(self._heartbeats_check),
|
||||
"feishu_subscriptions": self._safe_call(
|
||||
self._feishu_subscriptions_check
|
||||
),
|
||||
}
|
||||
statuses = {item[ObservabilityKey.STATUS] for item in checks.values()}
|
||||
if statuses & {ObservabilityStatus.ERROR, ObservabilityStatus.DEGRADED}:
|
||||
overall_status = ObservabilityStatus.DEGRADED
|
||||
else:
|
||||
overall_status = ObservabilityStatus.OK
|
||||
return {
|
||||
ObservabilityKey.STATUS: overall_status,
|
||||
ObservabilityKey.CHECKS: checks,
|
||||
}
|
||||
|
||||
def metrics(self) -> dict[str, Any]:
|
||||
return {
|
||||
ObservabilityKey.METRICS: {
|
||||
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
|
||||
ObservabilityKey.WORKFLOWS: WorkflowService(self.db).count_by_status(),
|
||||
ObservabilityKey.AI_MEMORY: AIMemoryService(self.db).count_by_status(),
|
||||
ObservabilityKey.HEARTBEATS: self.heartbeat_summary(),
|
||||
ObservabilityKey.EVENTS: self._safe_call(
|
||||
lambda: EventService(self.db).count_by_status()
|
||||
),
|
||||
ObservabilityKey.WORKFLOWS: self._safe_call(
|
||||
lambda: WorkflowService(self.db).count_by_status()
|
||||
),
|
||||
ObservabilityKey.AI_MEMORY: self._safe_call(
|
||||
lambda: AIMemoryService(self.db).count_by_status()
|
||||
),
|
||||
ObservabilityKey.HEARTBEATS: self._safe_call(
|
||||
self.heartbeat_summary
|
||||
),
|
||||
"feishu_users": self._safe_call(self._feishu_user_metrics),
|
||||
"subscriptions": self._safe_call(self._subscription_metrics),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,24 +99,7 @@ class ObservabilityService:
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
) -> dict[str, Any]:
|
||||
now = utc_now()
|
||||
record = self.db.execute(
|
||||
select(SystemHeartbeat).where(
|
||||
SystemHeartbeat.component == component,
|
||||
SystemHeartbeat.instance_id == instance_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = SystemHeartbeat(
|
||||
component=component,
|
||||
instance_id=instance_id,
|
||||
status=status_value,
|
||||
last_seen_at=now,
|
||||
)
|
||||
self.db.add(record)
|
||||
else:
|
||||
record.status = status_value
|
||||
record.last_seen_at = now
|
||||
record.updated_at = now
|
||||
record = self._upsert_heartbeat(component, instance_id, status_value, now)
|
||||
AuditService(self.db).record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
@@ -115,7 +121,13 @@ class ObservabilityService:
|
||||
return self._serialize_heartbeat(record)
|
||||
|
||||
def heartbeat_summary(self) -> dict[str, Any]:
|
||||
records = list(self.db.execute(select(SystemHeartbeat)).scalars())
|
||||
records = list(
|
||||
self.db.execute(
|
||||
select(SystemHeartbeat)
|
||||
.where(SystemHeartbeat.last_seen_at >= self._heartbeat_retention_threshold())
|
||||
.order_by(SystemHeartbeat.component.asc(), SystemHeartbeat.instance_id.asc())
|
||||
).scalars()
|
||||
)
|
||||
threshold = self._heartbeat_stale_threshold()
|
||||
stale = [item for item in records if item.last_seen_at < threshold]
|
||||
active = len(records) - len(stale)
|
||||
@@ -132,31 +144,75 @@ class ObservabilityService:
|
||||
],
|
||||
}
|
||||
|
||||
def _database_check(self) -> dict[str, Any]:
|
||||
def _safe_call(self, operation: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
self.db.execute(text("select 1")).scalar()
|
||||
except Exception as exc:
|
||||
return operation()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
return {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.ERROR,
|
||||
ObservabilityMetricKey.ERROR: str(exc),
|
||||
ObservabilityMetricKey.ERROR: "unavailable",
|
||||
}
|
||||
|
||||
def _database_check(self) -> dict[str, Any]:
|
||||
self.db.execute(text("select 1")).scalar()
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||
|
||||
def _redis_check(self) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if not settings.task_queue_enabled:
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED}
|
||||
try:
|
||||
from redis import Redis
|
||||
from redis import Redis
|
||||
|
||||
Redis.from_url(settings.redis_url, socket_connect_timeout=1).ping()
|
||||
except Exception as exc:
|
||||
return {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.DEGRADED,
|
||||
ObservabilityMetricKey.ERROR: str(exc),
|
||||
}
|
||||
client = Redis.from_url(
|
||||
settings.redis_url,
|
||||
socket_connect_timeout=1,
|
||||
socket_timeout=1,
|
||||
)
|
||||
try:
|
||||
client.ping()
|
||||
finally:
|
||||
client.close()
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||
|
||||
def _upsert_heartbeat(
|
||||
self,
|
||||
component: str,
|
||||
instance_id: str,
|
||||
status_value: str,
|
||||
now: Any,
|
||||
) -> SystemHeartbeat:
|
||||
dialect_name = self.db.get_bind().dialect.name
|
||||
insert_factory = {
|
||||
"postgresql": postgresql_insert,
|
||||
"sqlite": sqlite_insert,
|
||||
}.get(dialect_name)
|
||||
if insert_factory is None:
|
||||
raise RuntimeError(f"Unsupported heartbeat database dialect: {dialect_name}")
|
||||
statement = insert_factory(SystemHeartbeat).values(
|
||||
component=component,
|
||||
instance_id=instance_id,
|
||||
status=status_value,
|
||||
last_seen_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
statement = statement.on_conflict_do_update(
|
||||
index_elements=["component", "instance_id"],
|
||||
set_={
|
||||
"status": status_value,
|
||||
"last_seen_at": now,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
self.db.execute(statement)
|
||||
return self.db.execute(
|
||||
select(SystemHeartbeat).where(
|
||||
SystemHeartbeat.component == component,
|
||||
SystemHeartbeat.instance_id == instance_id,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
def _events_check(self) -> dict[str, Any]:
|
||||
counts = EventService(self.db).count_by_status()
|
||||
failed = counts.get(EventStatus.FAILED, 0)
|
||||
@@ -196,6 +252,134 @@ class ObservabilityService:
|
||||
],
|
||||
}
|
||||
|
||||
def _feishu_subscriptions_check(self) -> dict[str, Any]:
|
||||
active = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushSubscription)
|
||||
.where(PushSubscription.status == PushSubscriptionStatus.ACTIVE)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
processable_delivery_filter = or_(
|
||||
and_(
|
||||
PushDelivery.status.in_(
|
||||
[
|
||||
PushDeliveryStatus.PENDING,
|
||||
PushDeliveryStatus.RETRY,
|
||||
]
|
||||
),
|
||||
PushDelivery.next_attempt_at.is_not(None),
|
||||
),
|
||||
and_(
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_until.is_not(None),
|
||||
),
|
||||
)
|
||||
processable_deliveries = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushDelivery)
|
||||
.where(processable_delivery_filter)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if not active and not processable_deliveries:
|
||||
return {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED,
|
||||
"active": 0,
|
||||
"processable_deliveries": 0,
|
||||
}
|
||||
settings = get_settings()
|
||||
app_id = str(settings.feishu_app_id or "").strip()
|
||||
credentials_configured = bool(app_id and settings.feishu_app_secret)
|
||||
active_tenant_count = int(
|
||||
self.db.scalar(
|
||||
select(func.count(func.distinct(FeishuUser.tenant_key)))
|
||||
.select_from(PushSubscription)
|
||||
.join(FeishuUser, FeishuUser.id == PushSubscription.owner_id)
|
||||
.outerjoin(
|
||||
PushDelivery,
|
||||
PushDelivery.subscription_id == PushSubscription.id,
|
||||
)
|
||||
.where(
|
||||
or_(
|
||||
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
|
||||
processable_delivery_filter,
|
||||
)
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
ticket_configured = False
|
||||
default_tenant_configured = bool(
|
||||
str(settings.feishu_default_tenant_key or "").strip()
|
||||
)
|
||||
reasons: list[str] = []
|
||||
if not credentials_configured:
|
||||
reasons.append("credentials_missing")
|
||||
if settings.feishu_app_type == FeishuAppType.STORE:
|
||||
database_ticket = (
|
||||
FeishuAppTicketService(self.db).get_ticket(app_id)
|
||||
if app_id
|
||||
else None
|
||||
)
|
||||
ticket_configured = bool(
|
||||
str(database_ticket or settings.feishu_app_ticket or "").strip()
|
||||
)
|
||||
if not ticket_configured:
|
||||
reasons.append("app_ticket_missing")
|
||||
if not default_tenant_configured:
|
||||
reasons.append("default_tenant_missing")
|
||||
elif active_tenant_count > 1:
|
||||
reasons.append("self_app_multiple_tenants")
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.OK
|
||||
if not reasons
|
||||
else ObservabilityStatus.DEGRADED
|
||||
),
|
||||
"active": active,
|
||||
"processable_deliveries": processable_deliveries,
|
||||
"app_type": settings.feishu_app_type,
|
||||
"credentials_configured": credentials_configured,
|
||||
"ticket_configured": ticket_configured,
|
||||
"default_tenant_configured": default_tenant_configured,
|
||||
"active_tenant_count": active_tenant_count,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
def _feishu_user_metrics(self) -> dict[str, int]:
|
||||
active = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(FeishuUser)
|
||||
.where(FeishuUser.status == FeishuUserStatus.ACTIVE)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"active": active}
|
||||
|
||||
def _subscription_metrics(self) -> dict[str, int]:
|
||||
active = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushSubscription)
|
||||
.where(PushSubscription.status == PushSubscriptionStatus.ACTIVE)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
delivery_rows = self.db.execute(
|
||||
select(PushDelivery.status, func.count()).group_by(PushDelivery.status)
|
||||
).all()
|
||||
deliveries = {str(status_value): int(count) for status_value, count in delivery_rows}
|
||||
return {
|
||||
"active": active,
|
||||
"pending": deliveries.get(PushDeliveryStatus.PENDING, 0)
|
||||
+ deliveries.get(PushDeliveryStatus.RETRY, 0),
|
||||
"failed": deliveries.get(PushDeliveryStatus.FAILED, 0),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_heartbeat(record: SystemHeartbeat) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -209,3 +393,12 @@ class ObservabilityService:
|
||||
def _heartbeat_stale_threshold() -> Any:
|
||||
settings = get_settings()
|
||||
return utc_now() - timedelta(seconds=settings.heartbeat_interval_seconds * 3)
|
||||
|
||||
@staticmethod
|
||||
def _heartbeat_retention_threshold() -> Any:
|
||||
settings = get_settings()
|
||||
retention_seconds = max(
|
||||
settings.heartbeat_retention_seconds,
|
||||
settings.heartbeat_interval_seconds * 3,
|
||||
)
|
||||
return utc_now() - timedelta(seconds=retention_seconds)
|
||||
|
||||
Reference in New Issue
Block a user