feat: initial HSAP platform
Huaxu Sentinel Active Safety Platform with embedded algorithm code, Docker Compose setup, and vendored dataset scaffolds for clone-and-run. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0
platform/as_platform/auth/__init__.py
Normal file
0
platform/as_platform/auth/__init__.py
Normal file
71
platform/as_platform/auth/deps.py
Normal file
71
platform/as_platform/auth/deps.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""FastAPI 认证依赖。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from as_platform.auth.jwt import decode_access_token
|
||||
from as_platform.config import DEV_AUTH_ENABLED
|
||||
from as_platform.db.engine import get_db
|
||||
from as_platform.db.init_db import user_has_permission
|
||||
from as_platform.db.models import Role, User
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _load_user(db: Session, user_id: int) -> User | None:
|
||||
return (
|
||||
db.query(User)
|
||||
.options(joinedload(User.roles).joinedload(Role.permissions))
|
||||
.filter(User.id == user_id, User.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_current_user_optional(
|
||||
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> User | None:
|
||||
if not creds:
|
||||
return None
|
||||
payload = decode_access_token(creds.credentials)
|
||||
if not payload or "sub" not in payload:
|
||||
return None
|
||||
return _load_user(db, int(payload["sub"]))
|
||||
|
||||
|
||||
def get_current_user(
|
||||
user: Annotated[User | None, Depends(get_current_user_optional)],
|
||||
) -> User:
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="未登录,请先飞书登录")
|
||||
return user
|
||||
|
||||
|
||||
def require_permission(permission: str):
|
||||
def _dep(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
if not user_has_permission(user, permission):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=f"缺少权限: {permission}")
|
||||
return user
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
def require_any_permission(*permissions: str):
|
||||
def _dep(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
if any(user_has_permission(user, p) for p in permissions):
|
||||
return user
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
def can_submit_action(user: User, action: str) -> bool:
|
||||
if user_has_permission(user, "write:approval_submit"):
|
||||
return True
|
||||
if action == "register_batch" and user_has_permission(user, "write:approval_submit:register"):
|
||||
return True
|
||||
return False
|
||||
142
platform/as_platform/auth/feishu.py
Normal file
142
platform/as_platform/auth/feishu.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""飞书 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}"
|
||||
|
||||
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", {})
|
||||
29
platform/as_platform/auth/jwt.py
Normal file
29
platform/as_platform/auth/jwt.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""JWT 令牌。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from as_platform.config import JWT_EXPIRE_HOURS, JWT_SECRET
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def create_access_token(user_id: int, extra: dict[str, Any] | None = None) -> str:
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
68
platform/as_platform/auth/users.py
Normal file
68
platform/as_platform/auth/users.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""用户服务。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from as_platform.db.init_db import assign_default_role
|
||||
from as_platform.db.models import Role, User
|
||||
|
||||
|
||||
def get_user_by_id(db: Session, user_id: int) -> User | None:
|
||||
return (
|
||||
db.query(User)
|
||||
.options(joinedload(User.roles).joinedload(Role.permissions))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def upsert_feishu_user(db: Session, info: dict[str, Any]) -> User:
|
||||
open_id = info.get("open_id")
|
||||
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)
|
||||
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.feishu_tenant_key = info.get("tenant_key") or user.feishu_tenant_key
|
||||
department_ids = info.get("department_ids")
|
||||
if isinstance(department_ids, list):
|
||||
user.feishu_department_ids_json = json.dumps(department_ids, ensure_ascii=False)
|
||||
user.name = info.get("name") or user.name
|
||||
user.email = info.get("email") or user.email
|
||||
user.avatar_url = info.get("avatar_url") or user.avatar_url
|
||||
user.is_active = True
|
||||
db.flush()
|
||||
assign_default_role(db, user)
|
||||
db.refresh(user)
|
||||
return get_user_by_id(db, user.id) # type: ignore
|
||||
|
||||
|
||||
def get_or_create_dev_user(db: Session, name: str = "开发用户") -> User:
|
||||
user = db.query(User).filter(User.name == name, User.feishu_open_id.is_(None)).first()
|
||||
if not user:
|
||||
user = User(name=name, email="dev@local")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
admin = db.query(Role).filter_by(code="admin").first()
|
||||
if admin:
|
||||
user.roles = [admin]
|
||||
db.flush()
|
||||
return get_user_by_id(db, user.id) # type: ignore
|
||||
|
||||
|
||||
def list_users(db: Session) -> list[User]:
|
||||
return db.query(User).options(joinedload(User.roles)).order_by(User.id).all()
|
||||
|
||||
|
||||
def set_user_roles(db: Session, user_id: int, role_codes: list[str]) -> User | None:
|
||||
user = get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
roles = db.query(Role).filter(Role.code.in_(role_codes)).all()
|
||||
user.roles = roles
|
||||
db.flush()
|
||||
return user
|
||||
Reference in New Issue
Block a user