feat: CVAT 标注引擎、我的标注收件箱与 ADAS Cuboid 送标

- 统一标注引擎为 CVAT:客户端/配置/格式转换、iframe 标注页、docker-compose.cvat.yml 与 no_auth 补丁
- 移除 Label Studio 相关配置与构建脚本,清理 embedded.bak 备份与误提交的 node_modules
- 新增「我的标注」:跨 Campaign 收件箱、逐张清单、CVAT frame 跳转
- 飞书任务分配:通讯录同步选人、按量分配、分配后 DM 通知(含 my-tasks 链接)
- ADAS cuboid_7cls 数据湖接入:workflow 路径、register-batch、开标上传与标注同步
- 数据湖挂载 AS_DATA_LAKE_ROOT、datasets/adas 符号链接、reset_labeling 运维脚本
- 补充 docs/HANDOVER.md 项目交接文档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 17:25:28 +08:00
parent e72bc061c5
commit 672ef61e17
5281 changed files with 5194 additions and 1315918 deletions

View File

@@ -1,6 +1,7 @@
"""飞书 OAuth 登录。"""
from __future__ import annotations
import json
import secrets
import urllib.parse
from datetime import datetime, timedelta, timezone
@@ -8,8 +9,10 @@ from typing import Any
import httpx
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from as_platform.config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_REDIRECT_URI, JWT_SECRET
from as_platform.db.models import User
FEISHU_AUTHORIZE_URL = "https://passport.feishu.cn/suite/passport/oauth/authorize"
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/access_token"
@@ -18,6 +21,8 @@ FEISHU_TENANT_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_acces
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"
FEISHU_DEPARTMENT_CHILDREN_URL = "https://open.feishu.cn/open-apis/contact/v3/departments/{department_id}/children"
FEISHU_USERS_BY_DEPT_URL = "https://open.feishu.cn/open-apis/contact/v3/users/find_by_department"
STATE_ALG = "HS256"
STATE_EXPIRE_MINUTES = 10
@@ -144,79 +149,196 @@ def _get_contact_user_profile(
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)。
如果应用无通讯录权限则返回空列表。"""
def _parse_contact_user(item: dict[str, Any]) -> dict[str, Any]:
return {
"open_id": item.get("open_id"),
"union_id": item.get("union_id"),
"user_id": item.get("user_id"),
"name": item.get("name") or item.get("en_name") or item.get("nickname"),
"email": item.get("email") or item.get("enterprise_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", []),
}
def _paginate_feishu_get(
client: httpx.Client,
token: str,
url: str,
params: dict[str, str | int],
) -> tuple[list[dict[str, Any]], str | None]:
"""分页 GET返回 (items, error_message)。"""
items: list[dict[str, Any]] = []
page_token = ""
error_msg: str | None = None
while True:
req = dict(params)
if page_token:
req["page_token"] = page_token
resp = client.get(url, params=req, headers={"Authorization": f"Bearer {token}"})
data = resp.json() if resp.content else {}
if resp.status_code >= 400 or data.get("code") not in (0, None):
return items, data.get("msg") or f"HTTP {resp.status_code}"
if data.get("code") == 0:
items.extend(data.get("data", {}).get("items", []))
page_token = data.get("data", {}).get("page_token", "")
if not page_token:
break
return items, error_msg
def _fetch_department_map(client: httpx.Client, token: str) -> tuple[dict[str, str], str | None]:
"""递归拉取全部部门,返回 {open_department_id: name}。"""
dept_map: dict[str, str] = {"0": "全员"}
# 优先:从根部门递归子部门(需全员通讯录范围)
children, err = _paginate_feishu_get(
client,
token,
FEISHU_DEPARTMENT_CHILDREN_URL.format(department_id="0"),
{
"department_id_type": "open_department_id",
"fetch_child": "true",
"page_size": 50,
},
)
if err and "no dept authority" in err:
return {}, err
for item in children:
did = item.get("open_department_id", "")
if did:
dept_map[did] = item.get("name", "") or did
if dept_map and len(dept_map) > 1:
return dept_map, None
# 回退:平铺部门列表
flat, err2 = _paginate_feishu_get(
client,
token,
FEISHU_DEPARTMENTS_URL,
{"page_size": 50, "department_id_type": "open_department_id"},
)
for item in flat:
did = item.get("open_department_id", "")
if did:
dept_map[did] = item.get("name", "") or did
return dept_map, err2 or err
def _fetch_users_by_department(
client: httpx.Client, token: str, department_id: str
) -> tuple[list[dict[str, Any]], str | None]:
raw, err = _paginate_feishu_get(
client,
token,
FEISHU_USERS_BY_DEPT_URL,
{
"department_id": department_id,
"user_id_type": "open_id",
"department_id_type": "open_department_id",
"page_size": 50,
},
)
return [_parse_contact_user(x) for x in raw if x.get("open_id")], err
def _fetch_users_list(
client: httpx.Client,
token: str,
*,
department_id: str | None = None,
page_size: int = 50,
) -> tuple[list[dict[str, Any]], str | None]:
params: dict[str, str | int] = {
"user_id_type": "open_id",
"department_id_type": "open_department_id",
"page_size": min(page_size, 50),
}
if department_id is not None:
params["department_id"] = department_id
raw, err = _paginate_feishu_get(client, token, FEISHU_CONTACT_USERS_URL, params)
return [_parse_contact_user(x) for x in raw if x.get("open_id")], err
def fetch_feishu_users(page_size: int = 50) -> tuple[list[dict[str, Any]], dict[str, str], str | None]:
"""从飞书通讯录拉取组织用户(按部门递归 + 多策略回退)。
返回 (users, dept_name_map, error_message)。
若应用通讯录范围不是「全部员工」,可能只能同步到极少数成员。
"""
if not is_feishu_configured():
return [], {}
return [], {}, "飞书应用未配置"
error_msg: str | None = None
scope_warning: str | None = None
users_by_id: dict[str, dict[str, Any]] = {}
try:
with httpx.Client(timeout=30) as client:
with httpx.Client(timeout=60) as client:
token = _get_tenant_access_token(client)
dept_map, dept_err = _fetch_department_map(client, token)
# 先获取部门列表(如果应用有通讯录权限
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 # 无需部门信息也可同步用户
# 策略 1按部门拉取直属用户可覆盖全员
if dept_map:
dept_ids = [d for d in dept_map if d != "0"] or ["0"]
if "0" not in dept_ids:
dept_ids = ["0", *dept_ids]
for did in dept_ids:
batch, err = _fetch_users_by_department(client, token, did)
if err and not error_msg:
error_msg = err
for u in batch:
oid = u.get("open_id")
if oid:
users_by_id[oid] = u
# 再获取用户列表(如无通讯录权限则跳过)
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 # 无通讯录权限,跳过用户列表拉取
# 策略 2根部门用户列表
if not users_by_id:
batch, err = _fetch_users_list(client, token, department_id="0", page_size=page_size)
if err and not error_msg:
error_msg = err
for u in batch:
oid = u.get("open_id")
if oid:
users_by_id[oid] = u
return users, dept_map
except Exception:
return [], {}
# 策略 3权限范围内独立成员回退通常很少
if not users_by_id:
batch, err = _fetch_users_list(client, token, page_size=page_size)
if err and not error_msg:
error_msg = err
for u in batch:
oid = u.get("open_id")
if oid:
users_by_id[oid] = u
if dept_err and "no dept authority" in (dept_err or ""):
scope_warning = (
"应用通讯录范围未包含「全部员工」,无法按部门拉取全员。"
"请在开放平台 → 版本管理与发布 → 可用范围/通讯录权限 设为全部员工后重新发布。"
)
elif len(users_by_id) <= 5 and dept_err:
scope_warning = (
f"仅同步到 {len(users_by_id)} 人,可能通讯录范围过窄。"
"请将应用通讯录权限范围调整为全部员工。"
)
users = list(users_by_id.values())
if scope_warning:
# 附带在 error 字段供前端展示(有用户时不算硬错误)
if not users:
error_msg = error_msg or scope_warning
elif not error_msg:
error_msg = scope_warning
return users, dept_map, error_msg if not users else (scope_warning or None)
except Exception as exc:
return [], {}, str(exc)
def sync_feishu_users_to_db(db: Session) -> dict[str, int]:
"""同步飞书用户到数据库,返回 {created, updated, total}。"""
users, dept_map = fetch_feishu_users()
def sync_feishu_users_to_db(db: Session) -> dict[str, int | str | None]:
"""同步飞书用户到数据库,返回 {created, updated, total, error}。"""
users, dept_map, error_msg = fetch_feishu_users()
created, updated = 0, 0
for info in users:
open_id = info.get("open_id")
@@ -231,7 +353,12 @@ def sync_feishu_users_to_db(db: Session) -> dict[str, int]:
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.name = (
info.get("name")
or info.get("email")
or user.name
or f"飞书用户-{open_id[-6:]}"
)
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")
@@ -240,4 +367,4 @@ def sync_feishu_users_to_db(db: Session) -> dict[str, int]:
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)}
return {"created": created, "updated": updated, "total": len(users), "error": error_msg}