feat: 添加飞书用户模块和订阅功能支持

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

View File

@@ -0,0 +1,22 @@
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 FeishuMention, FeishuPrincipal
from app.modules.feishu_users.services import (
FeishuIdentityService,
FeishuUserManagementService,
)
__all__ = [
"FeishuCapability",
"FeishuIdentityService",
"FeishuMention",
"FeishuPrincipal",
"FeishuUser",
"FeishuUserManagementService",
"FeishuUserRole",
"FeishuUserStatus",
]

View File

@@ -0,0 +1,21 @@
from hashlib import sha256
_ADMIN_BOOTSTRAP_TOMBSTONE_DOMAIN = (
b"company-ai-platform:feishu-admin-bootstrap-tombstone:v1\0"
)
def admin_bootstrap_identity_hash(tenant_key: str, open_id: str) -> str:
"""Return a domain-separated digest used only to prevent admin re-grants."""
tenant_bytes = tenant_key.encode("utf-8")
open_id_bytes = open_id.encode("utf-8")
identity = b"".join(
(
len(tenant_bytes).to_bytes(4, "big"),
tenant_bytes,
len(open_id_bytes).to_bytes(4, "big"),
open_id_bytes,
)
)
return sha256(_ADMIN_BOOTSTRAP_TOMBSTONE_DOMAIN + identity).hexdigest()

View File

@@ -0,0 +1,86 @@
from enum import StrEnum
from typing import Iterable
class FeishuUserRole(StrEnum):
USER = "user"
ADMIN = "admin"
class FeishuUserStatus(StrEnum):
ACTIVE = "active"
DISABLED = "disabled"
class FeishuCapability(StrEnum):
PERSONAL_AI = "personal_ai"
PERSONAL_DATA = "personal_data"
PRIVATE_SUBSCRIPTION = "private_subscription"
PERSONAL_MARKET = "personal_market"
COMPANY_REPORTS = "company_reports"
COMPANY_RULES = "company_rules"
USER_ADMINISTRATION = "user_administration"
GROUP_SUBSCRIPTION = "group_subscription"
class FeishuUserAuditAction(StrEnum):
REGISTER = "feishu.user.register"
AUTHENTICATE = "feishu.user.authenticate"
PERMISSION_DENIED = "feishu.permission.denied"
LIST = "feishu.user.list"
READ = "feishu.user.read"
UPDATE = "feishu.user.update"
UPDATE_DENIED = "feishu.user.update_denied"
FEISHU_USER_CODE_PREFIX = "FSU"
FEISHU_USER_TARGET_TYPE = "feishu-user"
DEFAULT_FEISHU_USER_TIMEZONE = "Asia/Shanghai"
LAST_ACTIVE_ADMIN_ERROR = "The last active Feishu administrator cannot be changed"
FEISHU_USER_NOT_FOUND = "Feishu user not found"
INVALID_FEISHU_IDENTITY = "tenant_key and open_id are required"
INVALID_FEISHU_TIMEZONE = "Invalid IANA timezone"
INVALID_QUIET_HOURS = "quiet_hours_start and quiet_hours_end must both be set or cleared"
INVALID_ADMIN_IDENTITY = (
"FEISHU_ADMIN_IDENTITIES entries must use tenant_key:open_id format"
)
_USER_CAPABILITIES = frozenset(
{
FeishuCapability.PERSONAL_AI,
FeishuCapability.PERSONAL_DATA,
FeishuCapability.PRIVATE_SUBSCRIPTION,
FeishuCapability.PERSONAL_MARKET,
}
)
_ADMIN_CAPABILITIES = frozenset(FeishuCapability)
def capabilities_for_role(role: str | FeishuUserRole) -> frozenset[FeishuCapability]:
"""Return the fixed capability set for a Feishu user role."""
if FeishuUserRole(role) == FeishuUserRole.ADMIN:
return _ADMIN_CAPABILITIES
return _USER_CAPABILITIES
def parse_admin_identities(
value: str | Iterable[str] | None,
) -> frozenset[tuple[str, str]]:
"""Parse exact tenant/open-id pairs used only for initial administrator creation."""
if value is None:
return frozenset()
entries = value.split(",") if isinstance(value, str) else value
identities: set[tuple[str, str]] = set()
for entry in entries:
text = str(entry).strip()
if not text:
continue
tenant_key, separator, open_id = text.partition(":")
tenant_key = tenant_key.strip()
open_id = open_id.strip()
if not separator or not tenant_key or not open_id:
raise ValueError(INVALID_ADMIN_IDENTITY)
identities.add((tenant_key, open_id))
return frozenset(identities)

View File

@@ -0,0 +1,63 @@
from datetime import datetime, time
from sqlalchemy import DateTime, Integer, String, Time, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.feishu_users.constants import (
DEFAULT_FEISHU_USER_TIMEZONE,
FeishuUserRole,
FeishuUserStatus,
)
class FeishuUser(Base):
__tablename__ = "feishu_users"
__table_args__ = (
UniqueConstraint(
"tenant_key",
"open_id",
name="uq_feishu_user_tenant_open_id",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_key: Mapped[str] = mapped_column(String(128), index=True)
open_id: Mapped[str] = mapped_column(String(128), index=True)
union_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
role: Mapped[str] = mapped_column(
String(32),
default=FeishuUserRole.USER,
index=True,
)
status: Mapped[str] = mapped_column(
String(32),
default=FeishuUserStatus.ACTIVE,
index=True,
)
timezone: Mapped[str] = mapped_column(
String(64),
default=DEFAULT_FEISHU_USER_TIMEZONE,
)
quiet_hours_start: Mapped[time | None] = mapped_column(Time, nullable=True)
quiet_hours_end: Mapped[time | None] = mapped_column(Time, nullable=True)
last_active_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, 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,
)
class FeishuAdminBootstrapTombstone(Base):
"""Irreversible marker preventing a deleted initial admin from re-bootstrap."""
__tablename__ = "feishu_admin_bootstrap_tombstones"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
identity_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)

View File

@@ -0,0 +1,102 @@
from dataclasses import dataclass
from datetime import time
from fastapi import HTTPException, status
from app.modules.feishu_users.constants import (
FeishuCapability,
FeishuUserRole,
FeishuUserStatus,
capabilities_for_role,
)
from app.modules.feishu_users.models import FeishuUser
@dataclass(frozen=True, slots=True)
class FeishuMention:
"""Structured mention identity supplied by a verified Feishu event."""
key: str | None = None
name: str | None = None
tenant_key: str | None = None
open_id: str | None = None
union_id: str | None = None
user_id: str | None = None
@dataclass(frozen=True, slots=True)
class FeishuPrincipal:
"""Authenticated Feishu user plus the current chat context."""
owner_id: int
user_code: str
tenant_key: str
open_id: str
union_id: str | None
feishu_user_id: str | None
role: str
status: str
timezone: str
quiet_hours_start: time | None
quiet_hours_end: time | None
chat_id: str | None = None
chat_type: str | None = None
mentions: tuple[FeishuMention, ...] = ()
@property
def is_active(self) -> bool:
return self.status == FeishuUserStatus.ACTIVE
@property
def is_admin(self) -> bool:
return self.role == FeishuUserRole.ADMIN
def has_capability(self, capability: str | FeishuCapability) -> bool:
if not self.is_active:
return False
try:
required = FeishuCapability(capability)
return required in capabilities_for_role(self.role)
except ValueError:
return False
def require_active(self) -> None:
if not self.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feishu user is disabled",
)
def require_capability(self, capability: str | FeishuCapability) -> None:
self.require_active()
if not self.has_capability(capability):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feishu user is not authorized for this capability",
)
@classmethod
def from_user(
cls,
user: FeishuUser,
*,
chat_id: str | None = None,
chat_type: str | None = None,
mentions: tuple[FeishuMention, ...] = (),
) -> "FeishuPrincipal":
return cls(
owner_id=user.id,
user_code=user.code,
tenant_key=user.tenant_key,
open_id=user.open_id,
union_id=user.union_id,
feishu_user_id=user.user_id,
role=user.role,
status=user.status,
timezone=user.timezone,
quiet_hours_start=user.quiet_hours_start,
quiet_hours_end=user.quiet_hours_end,
chat_id=chat_id,
chat_type=chat_type,
mentions=mentions,
)

View File

@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.application.feishu.personal_data import FeishuPersonalDataService
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
from app.modules.feishu_users.schemas import (
FeishuUserListRead,
FeishuUserRead,
FeishuUserUpdate,
)
from app.modules.feishu_users.services import FeishuUserManagementService
from app.modules.personalization.schemas import ErasureResult
router = APIRouter(prefix="/users", dependencies=[Depends(require_api_key)])
@router.get("", response_model=FeishuUserListRead)
def list_users(
role: FeishuUserRole | None = None,
status_filter: FeishuUserStatus | None = Query(default=None, alias="status"),
tenant_key: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
items, total = FeishuUserManagementService(db).list_users(
role=role,
status_filter=status_filter,
tenant_key=tenant_key,
limit=limit,
offset=offset,
actor=principal.actor,
)
return {
"items": items,
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/{code}", response_model=FeishuUserRead)
def get_user(
code: str,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> FeishuUserRead:
return FeishuUserManagementService(db).get_user(
code,
actor=principal.actor,
)
@router.patch("/{code}", response_model=FeishuUserRead)
def update_user(
code: str,
payload: FeishuUserUpdate,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> FeishuUserRead:
return FeishuUserManagementService(db).update_user(
code,
changes=payload.model_dump(exclude_unset=True),
actor=principal.actor,
)
@router.delete("/{code}/personal-data", response_model=ErasureResult)
def erase_user_personal_data(
code: str,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> ErasureResult:
return FeishuPersonalDataService(db).erase_by_user_code(
code,
actor=principal.actor,
)

View File

@@ -0,0 +1,40 @@
from datetime import datetime, time
from pydantic import BaseModel, ConfigDict, Field
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
class FeishuUserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
code: str
tenant_key: str
open_id: str
union_id: str | None
user_id: str | None
role: str
status: str
timezone: str
quiet_hours_start: time | None
quiet_hours_end: time | None
last_active_at: datetime
created_at: datetime
updated_at: datetime
class FeishuUserListRead(BaseModel):
items: list[FeishuUserRead]
total: int
limit: int
offset: int
class FeishuUserUpdate(BaseModel):
model_config = ConfigDict(extra="ignore")
role: FeishuUserRole | None = None
status: FeishuUserStatus | None = None
timezone: str | None = Field(default=None, min_length=1, max_length=64)
quiet_hours_start: time | None = None
quiet_hours_end: time | None = None

View File

@@ -0,0 +1,9 @@
from app.modules.feishu_users.services.identity import FeishuIdentityService
from app.modules.feishu_users.services.management import (
FeishuUserManagementService,
)
__all__ = [
"FeishuIdentityService",
"FeishuUserManagementService",
]

View File

@@ -0,0 +1,161 @@
from collections.abc import Iterable
from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import inspect, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.utils.time import utc_now
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_CODE_PREFIX,
FEISHU_USER_TARGET_TYPE,
INVALID_FEISHU_IDENTITY,
FeishuUserAuditAction,
FeishuUserRole,
FeishuUserStatus,
parse_admin_identities,
)
from app.modules.feishu_users.bootstrap import admin_bootstrap_identity_hash
from app.modules.feishu_users.models import (
FeishuAdminBootstrapTombstone,
FeishuUser,
)
from app.modules.feishu_users.principal import FeishuPrincipal
class FeishuIdentityService:
"""Resolve or create identities only after the caller verifies the Feishu event."""
def __init__(
self,
db: Session,
admin_identities: str | Iterable[str] | None = None,
):
self.db = db
self.audit = AuditService(db)
configured = (
admin_identities
if admin_identities is not None
else getattr(get_settings(), "feishu_admin_identities", ())
)
self.admin_identities = parse_admin_identities(configured)
def resolve_or_register(
self,
*,
tenant_key: str,
open_id: str,
union_id: str | None = None,
user_id: str | None = None,
actor: str = ActorValue.FEISHU,
) -> FeishuPrincipal:
"""Return a principal for a previously verified Feishu sender."""
tenant_key = _required_identity_part(tenant_key)
open_id = _required_identity_part(open_id)
union_id = _optional_identity_part(union_id)
user_id = _optional_identity_part(user_id)
record = self.get_by_identity(tenant_key=tenant_key, open_id=open_id)
created = False
if record is None:
candidate = FeishuUser(
code=f"{FEISHU_USER_CODE_PREFIX}-{uuid4().hex[:20].upper()}",
tenant_key=tenant_key,
open_id=open_id,
union_id=union_id,
user_id=user_id,
role=self._initial_role(tenant_key, open_id),
status=FeishuUserStatus.ACTIVE,
last_active_at=utc_now(),
)
try:
with self.db.begin_nested():
self.db.add(candidate)
self.db.flush()
record = candidate
created = True
except IntegrityError:
record = self.get_by_identity(tenant_key=tenant_key, open_id=open_id)
if record is None:
raise
record.last_active_at = utc_now()
if union_id:
record.union_id = union_id
if user_id:
record.user_id = user_id
self.audit.record(
AuditLogCreate(
actor=actor,
source=AuditSource.FEISHU,
action=(
FeishuUserAuditAction.REGISTER
if created
else FeishuUserAuditAction.AUTHENTICATE
),
target_type=FEISHU_USER_TARGET_TYPE,
target_id=record.code,
risk_level=AuditRiskLevel.LOW,
response_payload={
"created": created,
"role": record.role,
"status": record.status,
},
)
)
self.db.commit()
self.db.refresh(record)
if created and inspect(self.db.get_bind()).has_table("market_watchlists"):
# Import locally so the identity domain does not create a module cycle.
from app.modules.market.service import MarketService
MarketService(self.db).claim_legacy_watchlist(record.id, record.open_id)
return FeishuPrincipal.from_user(record)
def get_by_identity(self, *, tenant_key: str, open_id: str) -> FeishuUser | None:
return self.db.execute(
select(FeishuUser).where(
FeishuUser.tenant_key == tenant_key,
FeishuUser.open_id == open_id,
)
).scalar_one_or_none()
def get_by_code(self, code: str) -> FeishuUser | None:
return self.db.execute(
select(FeishuUser).where(FeishuUser.code == code)
).scalar_one_or_none()
def _initial_role(self, tenant_key: str, open_id: str) -> str:
if (tenant_key, open_id) not in self.admin_identities:
return FeishuUserRole.USER
identity_hash = admin_bootstrap_identity_hash(tenant_key, open_id)
was_erased = self.db.scalar(
select(FeishuAdminBootstrapTombstone.id)
.where(
FeishuAdminBootstrapTombstone.identity_hash == identity_hash
)
.limit(1)
)
return FeishuUserRole.USER if was_erased is not None else FeishuUserRole.ADMIN
def _required_identity_part(value: Any) -> str:
text = str(value or "").strip()
if not text:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=INVALID_FEISHU_IDENTITY,
)
return text
def _optional_identity_part(value: Any) -> str | None:
text = str(value or "").strip()
return text or None

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