fix: resolve Feishu cross-app notification routing bug

Implement per-app open_id storage via user_feishu_open_ids table with
union_id-based cross-app user identification. WS handlers now auto-capture
open_id+union_id and resolve/associate user accounts. Schedule notifications
route through the correct bot's open_id instead of always falling back to 苹果.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-06 00:36:40 +08:00
parent 3dd098482e
commit f33bc461ff
11 changed files with 503 additions and 24 deletions

View File

@@ -0,0 +1,211 @@
"""飞书多应用 open_id 管理 — 存储/查询各应用下用户的 open_idunion_id 跨应用关联"""
from __future__ import annotations
import logging
from typing import Optional, Tuple
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
def get_app_id_for_agent(agent_id: str) -> str:
"""根据 agent_id 返回对应的飞书应用 app_id。默认返回主飞书应用苹果"""
if settings.LINGXI_AGENT_ID and agent_id == settings.LINGXI_AGENT_ID:
return settings.LINGXI_APP_ID or ""
if settings.ORANGE_AGENT_ID and agent_id == settings.ORANGE_AGENT_ID:
return settings.ORANGE_APP_ID or ""
if settings.SUYAO_AGENT_ID and agent_id == settings.SUYAO_AGENT_ID:
return settings.SUYAO_APP_ID or ""
if settings.TIANTIAN_AGENT_ID and agent_id == settings.TIANTIAN_AGENT_ID:
return settings.TIANTIAN_APP_ID or ""
return settings.FEISHU_APP_ID or ""
def save_open_id(
db: Session,
app_id: str,
open_id: str,
union_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> None:
"""保存或更新 (app_id, open_id) 记录。
如果已有同 app+open_id 的记录,更新 union_id 和 user_id
否则新建记录。user_id 可以为空(首次捕获时可能未知)。
"""
from app.models.user_feishu_open_id import UserFeishuOpenId
try:
existing = (
db.query(UserFeishuOpenId)
.filter(
UserFeishuOpenId.app_id == app_id,
UserFeishuOpenId.open_id == open_id,
)
.first()
)
if existing:
changed = False
if union_id and existing.union_id != union_id:
existing.union_id = union_id
changed = True
if user_id and existing.user_id != user_id:
existing.user_id = user_id
changed = True
if changed:
db.commit()
else:
record = UserFeishuOpenId(
user_id=user_id,
app_id=app_id,
open_id=open_id,
union_id=union_id,
)
db.add(record)
db.commit()
except Exception as e:
logger.warning("保存飞书 open_id 失败: %s", e)
db.rollback()
def find_user_id_by_open_id(db: Session, app_id: str, open_id: str) -> Optional[str]:
"""通过 (app_id, open_id) 查找已关联的 user_id。"""
from app.models.user_feishu_open_id import UserFeishuOpenId
try:
record = (
db.query(UserFeishuOpenId)
.filter(
UserFeishuOpenId.app_id == app_id,
UserFeishuOpenId.open_id == open_id,
)
.first()
)
return record.user_id if record else None
except Exception as e:
logger.warning("查询 user_id 失败: %s", e)
return None
def find_user_id_by_union_id(db: Session, union_id: str) -> Optional[str]:
"""通过 union_id 查找已关联的 user_id跨应用识别"""
from app.models.user_feishu_open_id import UserFeishuOpenId
try:
record = (
db.query(UserFeishuOpenId)
.filter(
UserFeishuOpenId.union_id == union_id,
UserFeishuOpenId.user_id.isnot(None),
)
.first()
)
return record.user_id if record else None
except Exception as e:
logger.warning("通过 union_id 查询 user_id 失败: %s", e)
return None
def get_open_id_for_app(db: Session, user_id: str, app_id: str) -> Optional[str]:
"""查询用户在某飞书应用下的 open_id。"""
from app.models.user_feishu_open_id import UserFeishuOpenId
try:
record = (
db.query(UserFeishuOpenId)
.filter(
UserFeishuOpenId.user_id == user_id,
UserFeishuOpenId.app_id == app_id,
)
.first()
)
return record.open_id if record else None
except Exception as e:
logger.warning("查询飞书 open_id 失败: %s", e)
return None
def link_open_id_to_user(
db: Session,
app_id: str,
open_id: str,
user_id: str,
union_id: Optional[str] = None,
) -> None:
"""将 (app_id, open_id) 关联到平台用户。已有记录则更新,否则创建。"""
save_open_id(db, app_id=app_id, open_id=open_id, union_id=union_id, user_id=user_id)
def resolve_user_and_save(
db: Session,
app_id: str,
open_id: str,
union_id: Optional[str] = None,
) -> Optional[str]:
"""WS 消息处理入口:自动解析用户身份并保存 open_id。
查找策略(按优先级):
1. 已有 (app_id, open_id) 记录中的 user_id
2. 通过 union_id 在 user_feishu_open_ids 中查找
3. 通过 open_id 匹配 User.feishu_open_id苹果旧数据兼容
4. 通过 union_id 匹配 User.feishu_union_id
找到用户后自动 link否则只保存 (app_id, open_id, union_id)。
Returns:
找到的 user_id未找到返回 None
"""
from app.models.user import User
# 1. 已有记录
existing_user_id = find_user_id_by_open_id(db, app_id, open_id)
if existing_user_id:
# 补充 union_id
if union_id:
save_open_id(db, app_id=app_id, open_id=open_id, union_id=union_id, user_id=existing_user_id)
return existing_user_id
# 2. 通过 union_id 查找
if union_id:
user_id_by_union = find_user_id_by_union_id(db, union_id)
if user_id_by_union:
link_open_id_to_user(db, app_id=app_id, open_id=open_id, user_id=user_id_by_union, union_id=union_id)
return user_id_by_union
# 3. 兼容旧 User.feishu_open_id苹果的 open_id
user = db.query(User).filter(User.feishu_open_id == open_id).first()
if user:
link_open_id_to_user(db, app_id=app_id, open_id=open_id, user_id=user.id, union_id=union_id)
return user.id
# 4. 有 union_id 但前面都没匹配到:尝试通过飞书 API 查找苹果 open_id 的 union_id
if union_id:
from app.services.feishu_app_service import lookup_union_id_by_open_id
users_with_apple = db.query(User).filter(User.feishu_open_id.isnot(None)).all()
for u in users_with_apple:
apple_union_id = lookup_union_id_by_open_id(u.feishu_open_id)
if apple_union_id and apple_union_id == union_id:
# 回填苹果记录的 union_id
from app.models.user_feishu_open_id import UserFeishuOpenId
apple_record = (
db.query(UserFeishuOpenId)
.filter(
UserFeishuOpenId.user_id == u.id,
UserFeishuOpenId.app_id == (settings.FEISHU_APP_ID or ""),
)
.first()
)
if apple_record:
apple_record.union_id = union_id
db.commit()
# 关联当前记录
link_open_id_to_user(db, app_id=app_id, open_id=open_id, user_id=u.id, union_id=union_id)
return u.id
# 5. 未找到只保存记录user_id 为空)
save_open_id(db, app_id=app_id, open_id=open_id, union_id=union_id)
return None