feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
291 lines
9.5 KiB
Python
291 lines
9.5 KiB
Python
from datetime import time
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.constants import ActorValue
|
|
from app.modules.audit.constants import AuditRiskLevel, AuditSource
|
|
from app.modules.audit.schemas import AuditLogCreate
|
|
from app.modules.audit.service import AuditService
|
|
from app.modules.feishu_users.constants import (
|
|
FEISHU_USER_NOT_FOUND,
|
|
FEISHU_USER_TARGET_TYPE,
|
|
INVALID_FEISHU_TIMEZONE,
|
|
INVALID_QUIET_HOURS,
|
|
LAST_ACTIVE_ADMIN_ERROR,
|
|
FeishuUserAuditAction,
|
|
FeishuUserRole,
|
|
FeishuUserStatus,
|
|
)
|
|
from app.modules.feishu_users.models import FeishuUser
|
|
|
|
_UPDATABLE_FIELDS = frozenset(
|
|
{
|
|
"role",
|
|
"status",
|
|
"timezone",
|
|
"quiet_hours_start",
|
|
"quiet_hours_end",
|
|
}
|
|
)
|
|
|
|
|
|
class FeishuUserManagementService:
|
|
"""Manage Feishu users while preserving an active administrator."""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.audit = AuditService(db)
|
|
|
|
def list_users(
|
|
self,
|
|
*,
|
|
role: str | None = None,
|
|
status_filter: str | None = None,
|
|
tenant_key: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
actor: str | None = None,
|
|
) -> tuple[list[FeishuUser], int]:
|
|
filters = []
|
|
if role:
|
|
filters.append(FeishuUser.role == FeishuUserRole(role))
|
|
if status_filter:
|
|
filters.append(FeishuUser.status == FeishuUserStatus(status_filter))
|
|
if tenant_key:
|
|
filters.append(FeishuUser.tenant_key == tenant_key)
|
|
total = int(
|
|
self.db.scalar(
|
|
select(func.count()).select_from(FeishuUser).where(*filters)
|
|
)
|
|
or 0
|
|
)
|
|
items = list(
|
|
self.db.execute(
|
|
select(FeishuUser)
|
|
.where(*filters)
|
|
.order_by(FeishuUser.created_at.desc(), FeishuUser.id.desc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
).scalars()
|
|
)
|
|
if actor:
|
|
self._audit_read(
|
|
actor=actor,
|
|
action=FeishuUserAuditAction.LIST,
|
|
response_payload={"count": len(items), "total": total},
|
|
)
|
|
return items, total
|
|
|
|
def get_user(self, code: str, *, actor: str | None = None) -> FeishuUser:
|
|
record = self._find_user(code)
|
|
if actor:
|
|
self._audit_read(
|
|
actor=actor,
|
|
action=FeishuUserAuditAction.READ,
|
|
target_id=record.code,
|
|
)
|
|
return record
|
|
|
|
def update_user(
|
|
self,
|
|
code: str,
|
|
*,
|
|
changes: dict[str, Any],
|
|
actor: str = ActorValue.API,
|
|
) -> FeishuUser:
|
|
unexpected = set(changes) - _UPDATABLE_FIELDS
|
|
if unexpected:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Unsupported Feishu user fields: {', '.join(sorted(unexpected))}",
|
|
)
|
|
active_admin_ids = (
|
|
self._active_admin_ids()
|
|
if {"role", "status"} & changes.keys()
|
|
else []
|
|
)
|
|
record = self.db.execute(
|
|
select(FeishuUser)
|
|
.where(FeishuUser.code == code)
|
|
.with_for_update()
|
|
).scalar_one_or_none()
|
|
if record is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=FEISHU_USER_NOT_FOUND,
|
|
)
|
|
if not changes:
|
|
return record
|
|
|
|
normalized = self._normalized_changes(record, changes)
|
|
proposed_role = normalized.get("role", record.role)
|
|
proposed_status = normalized.get("status", record.status)
|
|
removes_active_admin = (
|
|
record.role == FeishuUserRole.ADMIN
|
|
and record.status == FeishuUserStatus.ACTIVE
|
|
and (
|
|
proposed_role != FeishuUserRole.ADMIN
|
|
or proposed_status != FeishuUserStatus.ACTIVE
|
|
)
|
|
)
|
|
if removes_active_admin and active_admin_ids == [record.id]:
|
|
self.audit.record(
|
|
AuditLogCreate(
|
|
actor=actor,
|
|
source=AuditSource.API,
|
|
action=FeishuUserAuditAction.UPDATE_DENIED,
|
|
target_type=FEISHU_USER_TARGET_TYPE,
|
|
target_id=record.code,
|
|
risk_level=AuditRiskLevel.HIGH,
|
|
request_payload={
|
|
"role": proposed_role,
|
|
"status": proposed_status,
|
|
},
|
|
response_payload={
|
|
"result": "denied",
|
|
"reason": "last_active_admin",
|
|
},
|
|
status="denied",
|
|
)
|
|
)
|
|
self.db.commit()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=LAST_ACTIVE_ADMIN_ERROR,
|
|
)
|
|
|
|
before = _auditable_state(record)
|
|
for field, value in normalized.items():
|
|
setattr(record, field, value)
|
|
after = _auditable_state(record)
|
|
self.audit.record(
|
|
AuditLogCreate(
|
|
actor=actor,
|
|
source=AuditSource.API,
|
|
action=FeishuUserAuditAction.UPDATE,
|
|
target_type=FEISHU_USER_TARGET_TYPE,
|
|
target_id=record.code,
|
|
risk_level=AuditRiskLevel.HIGH,
|
|
request_payload={"before": before, "after": after},
|
|
response_payload={"updated_fields": sorted(normalized)},
|
|
)
|
|
)
|
|
self.db.commit()
|
|
self.db.refresh(record)
|
|
return record
|
|
|
|
def _find_user(self, code: str) -> FeishuUser:
|
|
record = self.db.execute(
|
|
select(FeishuUser).where(FeishuUser.code == code)
|
|
).scalar_one_or_none()
|
|
if record is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=FEISHU_USER_NOT_FOUND,
|
|
)
|
|
return record
|
|
|
|
def _normalized_changes(
|
|
self,
|
|
record: FeishuUser,
|
|
changes: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
normalized = dict(changes)
|
|
if "role" in normalized:
|
|
if normalized["role"] is None:
|
|
raise _unprocessable("role cannot be null")
|
|
normalized["role"] = FeishuUserRole(normalized["role"])
|
|
if "status" in normalized:
|
|
if normalized["status"] is None:
|
|
raise _unprocessable("status cannot be null")
|
|
normalized["status"] = FeishuUserStatus(normalized["status"])
|
|
if "timezone" in normalized:
|
|
timezone = str(normalized["timezone"] or "").strip()
|
|
if not timezone:
|
|
raise _unprocessable(INVALID_FEISHU_TIMEZONE)
|
|
try:
|
|
ZoneInfo(timezone)
|
|
except ZoneInfoNotFoundError as exc:
|
|
raise _unprocessable(INVALID_FEISHU_TIMEZONE) from exc
|
|
normalized["timezone"] = timezone
|
|
for field in ("quiet_hours_start", "quiet_hours_end"):
|
|
if field in normalized:
|
|
normalized[field] = _optional_time(normalized[field])
|
|
quiet_start = normalized.get("quiet_hours_start", record.quiet_hours_start)
|
|
quiet_end = normalized.get("quiet_hours_end", record.quiet_hours_end)
|
|
if (quiet_start is None) != (quiet_end is None):
|
|
raise _unprocessable(INVALID_QUIET_HOURS)
|
|
return normalized
|
|
|
|
def _active_admin_ids(self) -> list[int]:
|
|
return list(
|
|
self.db.execute(
|
|
select(FeishuUser.id)
|
|
.where(
|
|
FeishuUser.role == FeishuUserRole.ADMIN,
|
|
FeishuUser.status == FeishuUserStatus.ACTIVE,
|
|
)
|
|
.order_by(FeishuUser.id.asc())
|
|
.with_for_update()
|
|
).scalars()
|
|
)
|
|
|
|
def _audit_read(
|
|
self,
|
|
*,
|
|
actor: str,
|
|
action: str,
|
|
target_id: str | None = None,
|
|
response_payload: dict[str, Any] | None = None,
|
|
) -> None:
|
|
self.audit.record(
|
|
AuditLogCreate(
|
|
actor=actor,
|
|
source=AuditSource.API,
|
|
action=action,
|
|
target_type=FEISHU_USER_TARGET_TYPE,
|
|
target_id=target_id,
|
|
risk_level=AuditRiskLevel.LOW,
|
|
response_payload=response_payload,
|
|
)
|
|
)
|
|
self.db.commit()
|
|
|
|
|
|
def _auditable_state(record: FeishuUser) -> dict[str, Any]:
|
|
return {
|
|
"role": record.role,
|
|
"status": record.status,
|
|
"timezone": record.timezone,
|
|
"quiet_hours_start": (
|
|
record.quiet_hours_start.isoformat()
|
|
if record.quiet_hours_start is not None
|
|
else None
|
|
),
|
|
"quiet_hours_end": (
|
|
record.quiet_hours_end.isoformat()
|
|
if record.quiet_hours_end is not None
|
|
else None
|
|
),
|
|
}
|
|
|
|
|
|
def _optional_time(value: Any) -> time | None:
|
|
if value is None or isinstance(value, time):
|
|
return value
|
|
try:
|
|
return time.fromisoformat(str(value))
|
|
except ValueError as exc:
|
|
raise _unprocessable("Invalid quiet-hours time") from exc
|
|
|
|
|
|
def _unprocessable(detail: str) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=detail,
|
|
)
|