Files
company-ai-platform/app/modules/feishu_users/services/management.py
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
2026-07-27 08:02:17 +08:00

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,
)