feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
162 lines
5.6 KiB
Python
162 lines
5.6 KiB
Python
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
|