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