```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
22
app/modules/feishu_users/__init__.py
Normal file
22
app/modules/feishu_users/__init__.py
Normal 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",
|
||||
]
|
||||
21
app/modules/feishu_users/bootstrap.py
Normal file
21
app/modules/feishu_users/bootstrap.py
Normal 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()
|
||||
86
app/modules/feishu_users/constants.py
Normal file
86
app/modules/feishu_users/constants.py
Normal 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)
|
||||
63
app/modules/feishu_users/models.py
Normal file
63
app/modules/feishu_users/models.py
Normal 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)
|
||||
102
app/modules/feishu_users/principal.py
Normal file
102
app/modules/feishu_users/principal.py
Normal 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,
|
||||
)
|
||||
80
app/modules/feishu_users/routes.py
Normal file
80
app/modules/feishu_users/routes.py
Normal 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,
|
||||
)
|
||||
40
app/modules/feishu_users/schemas.py
Normal file
40
app/modules/feishu_users/schemas.py
Normal 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
|
||||
9
app/modules/feishu_users/services/__init__.py
Normal file
9
app/modules/feishu_users/services/__init__.py
Normal 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",
|
||||
]
|
||||
161
app/modules/feishu_users/services/identity.py
Normal file
161
app/modules/feishu_users/services/identity.py
Normal 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
|
||||
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