Files
HSAP/platform/as_platform/auth/feishu.py
Chengfang Lu e72bc061c5 feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation
Major changes:
- New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind
- 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理
- Data catalog with charts (DMS/ADAS/Lane 3-tab view)
- Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance
- Audit enhancements: batch operations, rejection categories, Feishu notifications
- Operation audit log (操作日志)
- World model simulation studio (仿真工坊)
- Dataset version management with snapshots and diff
- ADAS 7-class dataset integration (138K images organized + compressed)
- User management with Feishu integration and pagination
- CRUD/search/filter on all pages, card layout redesign
- PIL-optimized image overlay rendering
- Auto-snapshot on build, in_review workflow stage
- Removed embedded algorithm code (now in workspace)
2026-06-03 11:40:21 +08:00

244 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""飞书 OAuth 登录。"""
from __future__ import annotations
import secrets
import urllib.parse
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from jose import JWTError, jwt
from as_platform.config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_REDIRECT_URI, JWT_SECRET
FEISHU_AUTHORIZE_URL = "https://passport.feishu.cn/suite/passport/oauth/authorize"
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/access_token"
FEISHU_USER_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info"
FEISHU_TENANT_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
FEISHU_CONTACT_USER_URL = "https://open.feishu.cn/open-apis/contact/v3/users/{user_id}"
FEISHU_CONTACT_USERS_URL = "https://open.feishu.cn/open-apis/contact/v3/users"
FEISHU_DEPARTMENTS_URL = "https://open.feishu.cn/open-apis/contact/v3/departments"
STATE_ALG = "HS256"
STATE_EXPIRE_MINUTES = 10
def is_feishu_configured() -> bool:
return bool(FEISHU_APP_ID and FEISHU_APP_SECRET)
def build_authorize_url() -> tuple[str, str]:
# 使用签名 state避免服务重启/多进程导致内存 state 丢失
now = datetime.now(timezone.utc)
state = jwt.encode(
{
"nonce": secrets.token_urlsafe(12),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=STATE_EXPIRE_MINUTES)).timestamp()),
"typ": "feishu_oauth_state",
},
JWT_SECRET,
algorithm=STATE_ALG,
)
params = {
"client_id": FEISHU_APP_ID,
"redirect_uri": FEISHU_REDIRECT_URI,
"response_type": "code",
"state": state,
}
return f"{FEISHU_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}", state
def verify_state(state: str) -> bool:
try:
payload = jwt.decode(state, JWT_SECRET, algorithms=[STATE_ALG])
return payload.get("typ") == "feishu_oauth_state"
except JWTError:
return False
def exchange_code(code: str) -> dict[str, Any]:
"""用授权码换 user_access_token 并拉取用户信息。"""
with httpx.Client(timeout=30.0) as client:
token_resp = client.post(
FEISHU_TOKEN_URL,
json={
"grant_type": "authorization_code",
"code": code,
"app_id": FEISHU_APP_ID,
"app_secret": FEISHU_APP_SECRET,
},
)
token_resp.raise_for_status()
token_data = token_resp.json()
if token_data.get("code") != 0:
raise RuntimeError(token_data.get("msg") or "飞书 token 交换失败")
access_token = token_data["data"]["access_token"]
user_resp = client.get(
FEISHU_USER_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_resp.raise_for_status()
user_data = user_resp.json()
if user_data.get("code") != 0:
raise RuntimeError(user_data.get("msg") or "飞书用户信息获取失败")
info = user_data["data"]
department_ids: list[str] = []
user_id = info.get("user_id")
tenant_key = info.get("tenant_key")
open_id = info.get("open_id") or info.get("openId")
if open_id:
try:
tenant_access_token = _get_tenant_access_token(client)
contact_user = _get_contact_user_profile(client, tenant_access_token, open_id)
user_id = contact_user.get("user_id") or user_id
tenant_key = contact_user.get("tenant_key") or tenant_key
raw_department_ids = contact_user.get("department_ids")
if isinstance(raw_department_ids, list):
department_ids = [str(x) for x in raw_department_ids if x]
except Exception:
# 联系人接口失败时不阻断登录
department_ids = []
return {
"open_id": open_id,
"union_id": info.get("union_id") or info.get("unionId"),
"user_id": user_id,
"tenant_key": tenant_key,
"department_ids": department_ids,
"name": info.get("name") or info.get("en_name") or "飞书用户",
"email": info.get("email") or info.get("enterprise_email"),
"avatar_url": info.get("avatar_url") or info.get("avatar_big"),
}
def _get_tenant_access_token(client: httpx.Client) -> str:
resp = client.post(
FEISHU_TENANT_TOKEN_URL,
json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "飞书 tenant token 获取失败")
token = data.get("tenant_access_token")
if not token:
raise RuntimeError("飞书 tenant token 为空")
return token
def _get_contact_user_profile(
client: httpx.Client, tenant_access_token: str, open_id: str
) -> dict[str, Any]:
resp = client.get(
FEISHU_CONTACT_USER_URL.format(user_id=urllib.parse.quote(open_id, safe="")),
params={"user_id_type": "open_id", "department_id_type": "open_department_id"},
headers={"Authorization": f"Bearer {tenant_access_token}"},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg") or "飞书联系人信息获取失败")
return data.get("data", {}).get("user", {})
def fetch_feishu_users(page_size: int = 100) -> tuple[list[dict[str, Any]], dict[str, str]]:
"""从飞书通讯录批量拉取用户列表并获取部门名称映射,返回 (users, dept_name_map)。
如果应用无通讯录权限则返回空列表。"""
if not is_feishu_configured():
return [], {}
try:
with httpx.Client(timeout=30) as client:
token = _get_tenant_access_token(client)
# 先获取部门列表(如果应用有通讯录权限)
dept_map: dict[str, str] = {}
try:
page_token = ""
while True:
params: dict[str, str | int] = {"page_size": 100, "department_id_type": "open_department_id"}
if page_token:
params["page_token"] = page_token
resp = client.get(FEISHU_DEPARTMENTS_URL, params=params, headers={"Authorization": f"Bearer {token}"})
if resp.status_code >= 400:
break # 无部门权限,跳过
d = resp.json()
if d.get("code") == 0:
for item in d.get("data", {}).get("items", []):
dept_map[item.get("open_department_id", "")] = item.get("name", "")
page_token = d.get("data", {}).get("page_token", "")
if not page_token:
break
except Exception:
pass # 无需部门信息也可同步用户
# 再获取用户列表(如无通讯录权限则跳过)
users: list[dict[str, Any]] = []
try:
page_token = ""
while True:
params = {
"user_id_type": "open_id",
"department_id_type": "open_department_id",
"page_size": min(page_size, 100),
}
if page_token:
params["page_token"] = page_token # type: ignore
resp = client.get(FEISHU_CONTACT_USERS_URL, params=params, headers={"Authorization": f"Bearer {token}"}) # type: ignore
if resp.status_code >= 400:
break
d = resp.json()
if d.get("code") == 0:
for item in d.get("data", {}).get("items", []):
users.append({
"open_id": item.get("open_id"),
"union_id": item.get("union_id"),
"user_id": item.get("user_id"),
"name": item.get("name"),
"email": item.get("email"),
"mobile": item.get("mobile"),
"avatar_url": item.get("avatar", {}).get("avatar_240") if isinstance(item.get("avatar"), dict) else None,
"department_ids": item.get("department_ids", []),
})
page_token = d.get("data", {}).get("page_token", "")
if not page_token:
break
except Exception:
pass # 无通讯录权限,跳过用户列表拉取
return users, dept_map
except Exception:
return [], {}
def sync_feishu_users_to_db(db: Session) -> dict[str, int]:
"""同步飞书用户到数据库,返回 {created, updated, total}。"""
users, dept_map = fetch_feishu_users()
created, updated = 0, 0
for info in users:
open_id = info.get("open_id")
if not open_id:
continue
user = db.query(User).filter(User.feishu_open_id == open_id).first()
if not user:
user = User(feishu_open_id=open_id)
db.add(user)
created += 1
else:
updated += 1
user.feishu_union_id = info.get("union_id") or user.feishu_union_id
user.feishu_user_id = info.get("user_id") or user.feishu_user_id
user.name = info.get("name") or user.name
user.email = info.get("email") or user.email or user.email
user.avatar_url = info.get("avatar_url") or user.avatar_url
dept_ids = info.get("department_ids")
if isinstance(dept_ids, list) and dept_ids:
dept_names = [dept_map.get(str(did), str(did)) for did in dept_ids]
user.feishu_department_ids_json = json.dumps(dept_names, ensure_ascii=False)
user.is_active = True
db.flush()
return {"created": created, "updated": updated, "total": len(users)}