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