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

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

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