Files
company-ai-platform/app/modules/feishu/client.py
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

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

394 lines
14 KiB
Python

import json
import time
from threading import RLock
from typing import Any
import httpx
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
from app.modules.feishu.constants import (
FEISHU_APP_TICKET_MISSING,
FEISHU_APP_TOKEN_PATH,
FEISHU_AUTH_MISSING,
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS,
FEISHU_IMAGE_PATH,
FEISHU_MESSAGE_PATH,
FEISHU_RECEIVE_ID_MISSING,
FEISHU_STORE_TENANT_TOKEN_PATH,
FEISHU_SUCCESS_CODE,
FEISHU_TENANT_KEY_MISSING,
FEISHU_TENANT_TOKEN_PATH,
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS,
FeishuAppType,
FeishuMessageType,
FeishuPayloadKey,
FeishuReceiveIdType,
)
from app.modules.feishu.errors import FeishuAPIError
_RETRYABLE_PROVIDER_CODES = frozenset(
{
99991400,
99991401,
99991402,
99991403,
}
)
_RETRYABLE_PROVIDER_TERMS = (
"rate limit",
"too many request",
"temporar",
"timeout",
"busy",
"限流",
"频率",
"超时",
"繁忙",
)
class FeishuClient:
"""Small Feishu Open Platform client with tenant-isolated token caches."""
def __init__(self, db: Session | None = None) -> None:
self.settings = get_settings()
self.db = db
self._tenant_access_token: str | None = None
self._token_expires_at: float = 0
self._app_access_tokens: dict[str, tuple[str, float]] = {}
self._store_tenant_access_tokens: dict[tuple[str, str], tuple[str, float]] = {}
self._token_lock = RLock()
def _is_configured(self) -> bool:
return bool(self.settings.feishu_app_id and self.settings.feishu_app_secret)
def _get_tenant_access_token(self, tenant_key: str | None = None) -> str:
if not self._is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=FEISHU_AUTH_MISSING,
)
if self.settings.feishu_app_type == FeishuAppType.STORE:
return self._get_store_tenant_access_token(tenant_key)
return self._get_self_tenant_access_token()
def _get_self_tenant_access_token(self) -> str:
with self._token_lock:
if (
self._tenant_access_token
and time.time() < self._token_expires_at
):
return self._tenant_access_token
data = self._post(
f"{self.settings.feishu_base_url}{FEISHU_TENANT_TOKEN_PATH}",
operation="Feishu self tenant token request",
json={
FeishuPayloadKey.APP_ID: self.settings.feishu_app_id,
FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret,
},
)
token = self._required_token(
data,
FeishuPayloadKey.TENANT_ACCESS_TOKEN,
"Feishu self tenant token response",
)
self._tenant_access_token = token
self._token_expires_at = self._expires_at(data)
return token
def _get_store_app_access_token(self) -> str:
app_id = str(self.settings.feishu_app_id)
with self._token_lock:
cached = self._get_cached(self._app_access_tokens, app_id)
if cached is not None:
return cached
app_ticket = self._get_app_ticket(app_id)
data = self._post(
f"{self.settings.feishu_base_url}{FEISHU_APP_TOKEN_PATH}",
operation="Feishu store app token request",
json={
FeishuPayloadKey.APP_ID: app_id,
FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret,
FeishuPayloadKey.APP_TICKET: app_ticket,
},
)
token = self._required_token(
data,
FeishuPayloadKey.APP_ACCESS_TOKEN,
"Feishu store app token response",
)
self._app_access_tokens[app_id] = (token, self._expires_at(data))
return token
def _get_store_tenant_access_token(self, tenant_key: str | None) -> str:
normalized_tenant_key = str(tenant_key or "").strip()
if not normalized_tenant_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=FEISHU_TENANT_KEY_MISSING,
)
app_id = str(self.settings.feishu_app_id)
cache_key = (app_id, normalized_tenant_key)
with self._token_lock:
cached = self._get_cached(
self._store_tenant_access_tokens,
cache_key,
)
if cached is not None:
return cached
app_access_token = self._get_store_app_access_token()
data = self._post(
f"{self.settings.feishu_base_url}{FEISHU_STORE_TENANT_TOKEN_PATH}",
operation="Feishu store tenant token request",
json={
FeishuPayloadKey.APP_ACCESS_TOKEN: app_access_token,
FeishuPayloadKey.TENANT_KEY: normalized_tenant_key,
},
)
token = self._required_token(
data,
FeishuPayloadKey.TENANT_ACCESS_TOKEN,
"Feishu store tenant token response",
)
self._store_tenant_access_tokens[cache_key] = (
token,
self._expires_at(data),
)
return token
def _get_app_ticket(self, app_id: str) -> str:
ticket: Any = None
if self.db is not None:
from app.modules.feishu.app_tickets import FeishuAppTicketService
ticket = FeishuAppTicketService(self.db).get_ticket(app_id)
if ticket is not None and not isinstance(ticket, str):
ticket = getattr(ticket, "app_ticket", None) or getattr(
ticket,
"ticket",
None,
)
normalized = str(ticket or "").strip()
if not normalized:
normalized = str(self.settings.feishu_app_ticket or "").strip()
if not normalized:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=FEISHU_APP_TICKET_MISSING,
)
return normalized
def send_message(
self,
receive_id: str,
receive_id_type: str,
msg_type: str,
content: dict[str, Any],
uuid: str | None = None,
tenant_key: str | None = None,
) -> dict[str, Any]:
token = self._get_tenant_access_token(tenant_key)
headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)}
payload = {
FeishuPayloadKey.RECEIVE_ID: receive_id,
FeishuPayloadKey.MESSAGE_TYPE: msg_type,
FeishuPayloadKey.CONTENT: json.dumps(content, ensure_ascii=False),
}
if uuid:
payload[FeishuPayloadKey.UUID] = uuid
return self._post(
f"{self.settings.feishu_base_url}{FEISHU_MESSAGE_PATH}",
operation="Feishu message request",
headers=headers,
params={FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type},
json=payload,
)
def send_text(
self,
text: str,
receive_id: str | None = None,
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
uuid: str | None = None,
tenant_key: str | None = None,
) -> dict[str, Any]:
target_id = receive_id or self.settings.feishu_default_chat_id
if not target_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=FEISHU_RECEIVE_ID_MISSING,
)
resolved_tenant_key = tenant_key
if receive_id is None and not resolved_tenant_key:
resolved_tenant_key = self.settings.feishu_default_tenant_key
return self.send_message(
target_id,
receive_id_type,
FeishuMessageType.TEXT,
{FeishuPayloadKey.TEXT: text},
uuid,
resolved_tenant_key,
)
def upload_image(
self,
image: bytes,
filename: str = "lifecycle-report.png",
tenant_key: str | None = None,
) -> dict[str, Any]:
resolved_tenant_key = tenant_key or self.settings.feishu_default_tenant_key
token = self._get_tenant_access_token(resolved_tenant_key)
headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)}
return self._post(
f"{self.settings.feishu_base_url}{FEISHU_IMAGE_PATH}",
operation="Feishu image upload request",
timeout=30,
headers=headers,
data={FeishuPayloadKey.IMAGE_TYPE: FeishuPayloadKey.MESSAGE},
files={
FeishuPayloadKey.IMAGE: (filename, image, "image/png"),
},
)
def send_card(
self,
card: dict[str, Any],
receive_id: str | None = None,
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
uuid: str | None = None,
tenant_key: str | None = None,
) -> dict[str, Any]:
target_id = receive_id or self.settings.feishu_default_chat_id
if not target_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=FEISHU_RECEIVE_ID_MISSING,
)
resolved_tenant_key = tenant_key
if receive_id is None and not resolved_tenant_key:
resolved_tenant_key = self.settings.feishu_default_tenant_key
return self.send_message(
target_id,
receive_id_type,
FeishuMessageType.INTERACTIVE,
card,
uuid,
resolved_tenant_key,
)
def _post(
self,
url: str,
*,
operation: str,
timeout: int = 20,
**kwargs: Any,
) -> dict[str, Any]:
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(url, **kwargs)
except httpx.HTTPError:
raise FeishuAPIError(
f"{operation} failed",
retryable=True,
) from None
if not status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES:
raise FeishuAPIError(
f"{operation} returned an HTTP error",
retryable=_is_retryable_http_status(response.status_code),
http_status=response.status_code,
)
try:
data = response.json()
except ValueError:
raise FeishuAPIError(
f"{operation} response was not valid JSON",
retryable=True,
http_status=response.status_code,
) from None
if not isinstance(data, dict):
raise FeishuAPIError(
f"{operation} response was not a JSON object",
retryable=True,
http_status=response.status_code,
)
provider_code = data.get(FeishuPayloadKey.CODE)
if provider_code != FEISHU_SUCCESS_CODE:
raise FeishuAPIError(
f"{operation} returned a non-zero business code",
retryable=_is_retryable_business_error(data),
http_status=response.status_code,
provider_code=provider_code,
provider_response={
FeishuPayloadKey.CODE: provider_code,
},
)
return data
@staticmethod
def _required_token(
data: dict[str, Any],
key: FeishuPayloadKey,
operation: str,
) -> str:
token = data.get(key)
if not isinstance(token, str) or not token.strip():
raise FeishuAPIError(
f"{operation} did not include the required credential",
retryable=True,
provider_code=data.get(FeishuPayloadKey.CODE),
provider_response={
FeishuPayloadKey.CODE: data.get(FeishuPayloadKey.CODE),
},
)
return token
@staticmethod
def _expires_at(data: dict[str, Any]) -> float:
try:
expire_seconds = int(
data.get(
FeishuPayloadKey.EXPIRE,
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS,
)
)
except (TypeError, ValueError):
expire_seconds = FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS
usable_seconds = max(
1,
expire_seconds - FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS,
)
return time.time() + usable_seconds
@staticmethod
def _get_cached(
cache: dict[Any, tuple[str, float]],
key: Any,
) -> str | None:
entry = cache.get(key)
if entry is None:
return None
token, expires_at = entry
if time.time() < expires_at:
return token
cache.pop(key, None)
return None
def _is_retryable_http_status(status_code: int) -> bool:
return (
status_code == status.HTTP_429_TOO_MANY_REQUESTS
or status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR
)
def _is_retryable_business_error(data: dict[str, Any]) -> bool:
code = data.get(FeishuPayloadKey.CODE)
if code in _RETRYABLE_PROVIDER_CODES:
return True
message = str(data.get("msg") or "").casefold()
return any(term in message for term in _RETRYABLE_PROVIDER_TERMS)