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:
@@ -89,9 +89,16 @@ async def bind_feishu(
|
|||||||
if not data.open_id or not data.open_id.strip():
|
if not data.open_id or not data.open_id.strip():
|
||||||
raise HTTPException(status_code=400, detail="open_id 不能为空")
|
raise HTTPException(status_code=400, detail="open_id 不能为空")
|
||||||
|
|
||||||
current_user.feishu_open_id = data.open_id.strip()
|
open_id = data.open_id.strip()
|
||||||
|
current_user.feishu_open_id = open_id
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("飞书绑定成功: user=%s open_id=%s", current_user.id, data.open_id)
|
# 同步写入多应用 open_id 表(含 union_id 用于跨应用识别)
|
||||||
|
from app.services.feishu_open_id_service import save_open_id
|
||||||
|
from app.services.feishu_app_service import lookup_union_id_by_open_id
|
||||||
|
from app.core.config import settings
|
||||||
|
union_id = lookup_union_id_by_open_id(open_id)
|
||||||
|
save_open_id(db, app_id=settings.FEISHU_APP_ID or "", open_id=open_id, user_id=current_user.id, union_id=union_id)
|
||||||
|
logger.info("飞书绑定成功: user=%s open_id=%s union_id=%s", current_user.id, open_id, union_id)
|
||||||
return {"message": "飞书账号绑定成功"}
|
return {"message": "飞书账号绑定成功"}
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +139,13 @@ async def lookup_and_bind(
|
|||||||
|
|
||||||
current_user.feishu_open_id = open_id
|
current_user.feishu_open_id = open_id
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("飞书自动绑定成功: user=%s email=%s open_id=%s", current_user.id, current_user.email, open_id)
|
# 同步写入多应用 open_id 表(含 union_id 用于跨应用识别)
|
||||||
|
from app.services.feishu_open_id_service import save_open_id
|
||||||
|
from app.services.feishu_app_service import lookup_union_id_by_open_id
|
||||||
|
from app.core.config import settings
|
||||||
|
union_id = lookup_union_id_by_open_id(open_id)
|
||||||
|
save_open_id(db, app_id=settings.FEISHU_APP_ID or "", open_id=open_id, user_id=current_user.id, union_id=union_id)
|
||||||
|
logger.info("飞书自动绑定成功: user=%s email=%s open_id=%s union_id=%s", current_user.id, current_user.email, open_id, union_id)
|
||||||
|
|
||||||
# 发送测试消息
|
# 发送测试消息
|
||||||
from app.services.feishu_app_service import send_message_to_user
|
from app.services.feishu_app_service import send_message_to_user
|
||||||
@@ -176,6 +189,12 @@ async def bind_pending(
|
|||||||
open_id = ids[-1]
|
open_id = ids[-1]
|
||||||
current_user.feishu_open_id = open_id
|
current_user.feishu_open_id = open_id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# 同步写入多应用 open_id 表(含 union_id 用于跨应用识别)
|
||||||
|
from app.services.feishu_open_id_service import save_open_id
|
||||||
|
from app.services.feishu_app_service import lookup_union_id_by_open_id
|
||||||
|
from app.core.config import settings
|
||||||
|
union_id = lookup_union_id_by_open_id(open_id)
|
||||||
|
save_open_id(db, app_id=settings.FEISHU_APP_ID or "", open_id=open_id, user_id=current_user.id, union_id=union_id)
|
||||||
clear_pending_open_ids()
|
clear_pending_open_ids()
|
||||||
|
|
||||||
logger.info("飞书事件绑定成功: user=%s open_id=%s", current_user.id, open_id)
|
logger.info("飞书事件绑定成功: user=%s open_id=%s", current_user.id, open_id)
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ from app.models.agent_learning_pattern import AgentLearningPattern
|
|||||||
from app.models.agent_schedule import AgentSchedule
|
from app.models.agent_schedule import AgentSchedule
|
||||||
from app.models.knowledge_base import KnowledgeBase, Document, DocumentChunk
|
from app.models.knowledge_base import KnowledgeBase, Document, DocumentChunk
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
|
from app.models.user_feishu_open_id import UserFeishuOpenId
|
||||||
|
|
||||||
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification"]
|
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId"]
|
||||||
32
backend/app/models/user_feishu_open_id.py
Normal file
32
backend/app/models/user_feishu_open_id.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""用户飞书多应用 open_id 映射表 — 每个飞书应用给同一用户分配不同的 open_id"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, DateTime, func, UniqueConstraint, Index
|
||||||
|
from sqlalchemy.dialects.mysql import CHAR
|
||||||
|
from app.core.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class UserFeishuOpenId(Base):
|
||||||
|
"""用户在各飞书应用下的 open_id 映射。
|
||||||
|
|
||||||
|
飞书的 open_id 按应用隔离:同一个用户在苹果/灵犀/橙子/苏瑶/甜甜
|
||||||
|
各自拥有不同的 open_id。union_id 在同一租户下跨应用相同,用于跨应用识别用户。
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_feishu_open_ids"
|
||||||
|
|
||||||
|
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="主键")
|
||||||
|
user_id = Column(CHAR(36), nullable=True, index=True, comment="平台用户 ID(首次捕获时可能为空)")
|
||||||
|
app_id = Column(String(64), nullable=False, index=True, comment="飞书应用 app_id")
|
||||||
|
open_id = Column(String(64), nullable=False, comment="该应用下的用户 open_id")
|
||||||
|
union_id = Column(String(64), nullable=True, index=True, comment="飞书 union_id,跨应用唯一,用于关联同一用户")
|
||||||
|
created_at = Column(DateTime, default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("app_id", "open_id", name="uq_app_open_id"),
|
||||||
|
Index("ix_user_app", "user_id", "app_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<UserFeishuOpenId(user={self.user_id}, app={self.app_id})>"
|
||||||
@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def compute_next_run(cron_expression: str, after: Optional[datetime] = None, tz: str = "UTC") -> datetime:
|
def compute_next_run(cron_expression: str, after: Optional[datetime] = None, tz: str = "Asia/Shanghai") -> datetime:
|
||||||
"""根据 cron 表达式计算下一次执行时间。
|
"""根据 cron 表达式计算下一次执行时间。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -65,11 +65,16 @@ def create_execution_for_schedule(db: Session, schedule) -> Optional[str]:
|
|||||||
logger.warning("定时任务 %s 关联的 Agent %s 不存在", schedule.id, schedule.agent_id)
|
logger.warning("定时任务 %s 关联的 Agent %s 不存在", schedule.id, schedule.agent_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 创建执行记录(关联 schedule_id)
|
# 创建执行记录(关联 schedule_id),标记为定时任务提醒
|
||||||
execution = Execution(
|
execution = Execution(
|
||||||
agent_id=schedule.agent_id,
|
agent_id=schedule.agent_id,
|
||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
input_data={"message": schedule.input_message},
|
input_data={
|
||||||
|
"USER_INPUT": f"[定时任务提醒] {schedule.input_message}",
|
||||||
|
"query": f"[定时任务提醒] {schedule.input_message}",
|
||||||
|
"message": schedule.input_message,
|
||||||
|
"is_scheduled_reminder": True,
|
||||||
|
},
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
db.add(execution)
|
db.add(execution)
|
||||||
@@ -83,14 +88,24 @@ def create_execution_for_schedule(db: Session, schedule) -> Optional[str]:
|
|||||||
str(execution.id),
|
str(execution.id),
|
||||||
f"agent_{schedule.agent_id}",
|
f"agent_{schedule.agent_id}",
|
||||||
agent.workflow_config,
|
agent.workflow_config,
|
||||||
{"message": schedule.input_message},
|
{
|
||||||
|
"USER_INPUT": f"[定时任务提醒] {schedule.input_message}",
|
||||||
|
"query": f"[定时任务提醒] {schedule.input_message}",
|
||||||
|
"message": schedule.input_message,
|
||||||
|
"is_scheduled_reminder": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 无工作流配置:走简单 Agent 异步执行(传入已创建的 execution_id)
|
# 无工作流配置:走简单 Agent 异步执行(传入已创建的 execution_id)
|
||||||
from app.tasks.agent_tasks import execute_agent_task
|
from app.tasks.agent_tasks import execute_agent_task
|
||||||
task = execute_agent_task.delay(
|
task = execute_agent_task.delay(
|
||||||
str(schedule.agent_id),
|
str(schedule.agent_id),
|
||||||
{"message": schedule.input_message},
|
{
|
||||||
|
"USER_INPUT": f"[定时任务提醒] {schedule.input_message}",
|
||||||
|
"query": f"[定时任务提醒] {schedule.input_message}",
|
||||||
|
"message": schedule.input_message,
|
||||||
|
"is_scheduled_reminder": True,
|
||||||
|
},
|
||||||
execution_id=str(execution.id),
|
execution_id=str(execution.id),
|
||||||
)
|
)
|
||||||
execution.task_id = task.id
|
execution.task_id = task.id
|
||||||
@@ -186,7 +201,21 @@ def notify_schedule_result(db: Session, execution, status: str, error_message: O
|
|||||||
|
|
||||||
if status == "completed":
|
if status == "completed":
|
||||||
title = f"定时任务「{schedule.name}」执行成功"
|
title = f"定时任务「{schedule.name}」执行成功"
|
||||||
content = f"Agent 已按计划执行完成。"
|
# 优先使用 Agent 执行结果,其次使用定时任务的提醒内容
|
||||||
|
result_text = ""
|
||||||
|
if execution.output_data:
|
||||||
|
if isinstance(execution.output_data, dict):
|
||||||
|
result_text = (
|
||||||
|
execution.output_data.get("result")
|
||||||
|
or execution.output_data.get("output")
|
||||||
|
or execution.output_data.get("text")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
elif isinstance(execution.output_data, str):
|
||||||
|
result_text = execution.output_data
|
||||||
|
if not result_text:
|
||||||
|
result_text = schedule.input_message or ""
|
||||||
|
content = result_text if result_text else "Agent 已按计划执行完成。"
|
||||||
else:
|
else:
|
||||||
title = f"定时任务「{schedule.name}」执行失败"
|
title = f"定时任务「{schedule.name}」执行失败"
|
||||||
content = f"错误信息: {error_message or '未知错误'}"
|
content = f"错误信息: {error_message or '未知错误'}"
|
||||||
@@ -225,13 +254,13 @@ def notify_schedule_result(db: Session, execution, status: str, error_message: O
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("飞书 webhook 通知发送失败: %s", e)
|
logger.warning("飞书 webhook 通知发送失败: %s", e)
|
||||||
|
|
||||||
# 如果用户绑定了飞书账号,通过飞书应用发送通知
|
# 如果用户绑定了飞书账号,通过对应的飞书应用发送通知
|
||||||
try:
|
try:
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.feishu_app_service import send_message_to_user
|
from app.services.feishu_open_id_service import get_app_id_for_agent, get_open_id_for_app
|
||||||
|
|
||||||
schedule_user = db.query(User).filter(User.id == schedule.user_id).first()
|
schedule_user = db.query(User).filter(User.id == schedule.user_id).first()
|
||||||
if schedule_user and schedule_user.feishu_open_id:
|
if schedule_user:
|
||||||
detail_link = None
|
detail_link = None
|
||||||
try:
|
try:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -240,13 +269,46 @@ def notify_schedule_result(db: Session, execution, status: str, error_message: O
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
send_message_to_user(
|
agent_id = str(execution.agent_id) if execution.agent_id else ""
|
||||||
open_id=schedule_user.feishu_open_id,
|
app_id = get_app_id_for_agent(agent_id)
|
||||||
title=title,
|
per_app_open_id = get_open_id_for_app(db, schedule.user_id, app_id) if app_id else None
|
||||||
content=content,
|
send_ok = False
|
||||||
status=status,
|
|
||||||
detail_link=detail_link,
|
# 如果找到了该用户在此应用下的专属 open_id,直接用对应应用发送
|
||||||
)
|
if per_app_open_id and app_id:
|
||||||
|
if app_id == (settings.LINGXI_APP_ID or ""):
|
||||||
|
try:
|
||||||
|
from app.services.lingxi_app_service import send_message_to_user as send_msg
|
||||||
|
send_msg(open_id=per_app_open_id, title=title, content=content, status=status, detail_link=detail_link)
|
||||||
|
send_ok = True
|
||||||
|
except Exception:
|
||||||
|
logger.info("灵犀发送失败,fallback 到主飞书应用")
|
||||||
|
elif app_id == (settings.ORANGE_APP_ID or ""):
|
||||||
|
try:
|
||||||
|
from app.services.orange_app_service import send_message_to_user as send_msg
|
||||||
|
send_msg(open_id=per_app_open_id, title=title, content=content, status=status, detail_link=detail_link)
|
||||||
|
send_ok = True
|
||||||
|
except Exception:
|
||||||
|
logger.info("橙子发送失败,fallback 到主飞书应用")
|
||||||
|
elif app_id == (settings.SUYAO_APP_ID or ""):
|
||||||
|
try:
|
||||||
|
from app.services.suyao_app_service import send_message_to_user as send_msg
|
||||||
|
send_msg(open_id=per_app_open_id, title=title, content=content, status=status, detail_link=detail_link)
|
||||||
|
send_ok = True
|
||||||
|
except Exception:
|
||||||
|
logger.info("苏瑶发送失败,fallback 到主飞书应用")
|
||||||
|
elif app_id == (settings.TIANTIAN_APP_ID or ""):
|
||||||
|
try:
|
||||||
|
from app.services.tiantian_app_service import send_message_to_user as send_msg
|
||||||
|
send_msg(open_id=per_app_open_id, title=title, content=content, status=status, detail_link=detail_link)
|
||||||
|
send_ok = True
|
||||||
|
except Exception:
|
||||||
|
logger.info("甜甜发送失败,fallback 到主飞书应用")
|
||||||
|
|
||||||
|
# Fallback: 使用主飞书应用(苹果)的 open_id 发送
|
||||||
|
if not send_ok and schedule_user.feishu_open_id:
|
||||||
|
from app.services.feishu_app_service import send_message_to_user as send_msg
|
||||||
|
send_msg(open_id=schedule_user.feishu_open_id, title=title, content=content, status=status, detail_link=detail_link)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("飞书应用通知发送失败: %s", e)
|
logger.warning("飞书应用通知发送失败: %s", e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -194,6 +194,44 @@ def lookup_user_by_email(email: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_union_id_by_open_id(open_id: str) -> Optional[str]:
|
||||||
|
"""通过 open_id 查询飞书用户的 union_id(跨应用唯一标识)。
|
||||||
|
|
||||||
|
同一个用户的 union_id 在所有应用下相同,可用于跨应用关联。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
open_id: 飞书用户的 open_id(必须在当前苹果应用下有权限访问)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
union_id 字符串,未找到返回 None
|
||||||
|
"""
|
||||||
|
token = _get_tenant_access_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10) as client:
|
||||||
|
resp = client.get(
|
||||||
|
f"https://open.feishu.cn/open-apis/contact/v3/users/{open_id}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
if resp.is_success and result.get("code") == 0:
|
||||||
|
user_data = result.get("data", {}).get("user", {})
|
||||||
|
union_id = user_data.get("union_id")
|
||||||
|
if union_id:
|
||||||
|
logger.info("飞书 union_id 查询成功: open_id=%s union_id=%s", open_id[:20], union_id)
|
||||||
|
return union_id
|
||||||
|
logger.info("飞书用户未找到 union_id: open_id=%s", open_id[:20])
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.warning("飞书 union_id 查询失败: code=%s msg=%s", result.get("code"), result.get("msg"))
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("飞书 union_id 查询异常: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_verification_token() -> str:
|
def get_verification_token() -> str:
|
||||||
"""获取飞书应用的 Verification Token(用于验证事件回调)。"""
|
"""获取飞书应用的 Verification Token(用于验证事件回调)。"""
|
||||||
return settings.FEISHU_VERIFICATION_TOKEN
|
return settings.FEISHU_VERIFICATION_TOKEN
|
||||||
|
|||||||
211
backend/app/services/feishu_open_id_service.py
Normal file
211
backend/app/services/feishu_open_id_service.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""飞书多应用 open_id 管理 — 存储/查询各应用下用户的 open_id,union_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
|
||||||
@@ -70,6 +70,21 @@ def _get_sender_open_id(data) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sender_union_id(data) -> Optional[str]:
|
||||||
|
"""从 Feishu 消息事件中提取发送者 union_id(跨应用唯一)。"""
|
||||||
|
try:
|
||||||
|
ev = data.event
|
||||||
|
sender = getattr(ev, "sender", None)
|
||||||
|
if not sender:
|
||||||
|
return None
|
||||||
|
sender_id = getattr(sender, "sender_id", None)
|
||||||
|
if not sender_id:
|
||||||
|
return None
|
||||||
|
return getattr(sender_id, "union_id", None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_chat_type(data) -> str:
|
def _get_chat_type(data) -> str:
|
||||||
"""获取聊天类型。"""
|
"""获取聊天类型。"""
|
||||||
try:
|
try:
|
||||||
@@ -103,6 +118,7 @@ def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
|||||||
async def _handle_message_async(data):
|
async def _handle_message_async(data):
|
||||||
"""异步处理飞书消息。"""
|
"""异步处理飞书消息。"""
|
||||||
open_id = _get_sender_open_id(data)
|
open_id = _get_sender_open_id(data)
|
||||||
|
union_id = _get_sender_union_id(data)
|
||||||
chat_type = _get_chat_type(data)
|
chat_type = _get_chat_type(data)
|
||||||
text = _get_message_text(data)
|
text = _get_message_text(data)
|
||||||
|
|
||||||
@@ -128,11 +144,21 @@ async def _handle_message_async(data):
|
|||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
|
from app.services.feishu_open_id_service import resolve_user_and_save
|
||||||
|
|
||||||
db: Optional[Session] = None
|
db: Optional[Session] = None
|
||||||
try:
|
try:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# 自动保存/关联此应用的 open_id(跨应用识别)
|
||||||
|
resolved_uid = resolve_user_and_save(
|
||||||
|
db, app_id=settings.FEISHU_APP_ID or "",
|
||||||
|
open_id=open_id, union_id=union_id,
|
||||||
|
)
|
||||||
|
|
||||||
user = db.query(User).filter(User.feishu_open_id == open_id).first()
|
user = db.query(User).filter(User.feishu_open_id == open_id).first()
|
||||||
|
if not user and resolved_uid:
|
||||||
|
user = db.query(User).filter(User.id == resolved_uid).first()
|
||||||
if not user:
|
if not user:
|
||||||
_reply_to_feishu(open_id, "你的账号未绑定平台用户,请先在平台绑定飞书。")
|
_reply_to_feishu(open_id, "你的账号未绑定平台用户,请先在平台绑定飞书。")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ def _get_sender_open_id(data) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sender_union_id(data) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
ev = data.event
|
||||||
|
sender = getattr(ev, "sender", None)
|
||||||
|
if not sender:
|
||||||
|
return None
|
||||||
|
sender_id = getattr(sender, "sender_id", None)
|
||||||
|
if not sender_id:
|
||||||
|
return None
|
||||||
|
return getattr(sender_id, "union_id", None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_chat_type(data) -> str:
|
def _get_chat_type(data) -> str:
|
||||||
try:
|
try:
|
||||||
ev = data.event
|
ev = data.event
|
||||||
@@ -110,6 +124,7 @@ def _make_llm_logger(db, agent_id: Optional[str] = None, user_id: Optional[str]
|
|||||||
|
|
||||||
async def _handle_message_async(data):
|
async def _handle_message_async(data):
|
||||||
open_id = _get_sender_open_id(data)
|
open_id = _get_sender_open_id(data)
|
||||||
|
union_id = _get_sender_union_id(data)
|
||||||
chat_type = _get_chat_type(data)
|
chat_type = _get_chat_type(data)
|
||||||
text = _get_message_text(data)
|
text = _get_message_text(data)
|
||||||
|
|
||||||
@@ -124,11 +139,18 @@ async def _handle_message_async(data):
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
|
from app.services.feishu_open_id_service import resolve_user_and_save
|
||||||
|
|
||||||
db: Optional[Session] = None
|
db: Optional[Session] = None
|
||||||
try:
|
try:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# 自动保存/关联此应用的 open_id(跨应用识别)
|
||||||
|
resolved_uid = resolve_user_and_save(
|
||||||
|
db, app_id=settings.LINGXI_APP_ID or "",
|
||||||
|
open_id=open_id, union_id=union_id,
|
||||||
|
)
|
||||||
|
|
||||||
agent_id = settings.LINGXI_AGENT_ID
|
agent_id = settings.LINGXI_AGENT_ID
|
||||||
if not agent_id:
|
if not agent_id:
|
||||||
_reply_to_feishu(open_id, "灵犀尚未配置,请联系管理员。")
|
_reply_to_feishu(open_id, "灵犀尚未配置,请联系管理员。")
|
||||||
@@ -183,7 +205,7 @@ async def _handle_message_async(data):
|
|||||||
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
|
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
|
||||||
learning_enabled=bool(cfg.get("memory_learning", True)),
|
learning_enabled=bool(cfg.get("memory_learning", True)),
|
||||||
),
|
),
|
||||||
user_id=None,
|
user_id=resolved_uid,
|
||||||
memory_scope_id=str(agent.id),
|
memory_scope_id=str(agent.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,21 @@ def _get_sender_open_id(data) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sender_union_id(data) -> Optional[str]:
|
||||||
|
"""从消息事件中提取发送者 union_id(跨应用唯一)。"""
|
||||||
|
try:
|
||||||
|
ev = data.event
|
||||||
|
sender = getattr(ev, "sender", None)
|
||||||
|
if not sender:
|
||||||
|
return None
|
||||||
|
sender_id = getattr(sender, "sender_id", None)
|
||||||
|
if not sender_id:
|
||||||
|
return None
|
||||||
|
return getattr(sender_id, "union_id", None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_chat_type(data) -> str:
|
def _get_chat_type(data) -> str:
|
||||||
"""获取聊天类型。"""
|
"""获取聊天类型。"""
|
||||||
try:
|
try:
|
||||||
@@ -125,6 +140,7 @@ def _make_llm_logger(db, agent_id: Optional[str] = None, user_id: Optional[str]
|
|||||||
async def _handle_message_async(data):
|
async def _handle_message_async(data):
|
||||||
"""异步处理橙子消息 — 固定使用橙子助手 Agent。"""
|
"""异步处理橙子消息 — 固定使用橙子助手 Agent。"""
|
||||||
open_id = _get_sender_open_id(data)
|
open_id = _get_sender_open_id(data)
|
||||||
|
union_id = _get_sender_union_id(data)
|
||||||
chat_type = _get_chat_type(data)
|
chat_type = _get_chat_type(data)
|
||||||
text = _get_message_text(data)
|
text = _get_message_text(data)
|
||||||
|
|
||||||
@@ -139,11 +155,18 @@ async def _handle_message_async(data):
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
|
from app.services.feishu_open_id_service import resolve_user_and_save
|
||||||
|
|
||||||
db: Optional[Session] = None
|
db: Optional[Session] = None
|
||||||
try:
|
try:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# 自动保存/关联此应用的 open_id(跨应用识别)
|
||||||
|
resolved_uid = resolve_user_and_save(
|
||||||
|
db, app_id=settings.ORANGE_APP_ID or "",
|
||||||
|
open_id=open_id, union_id=union_id,
|
||||||
|
)
|
||||||
|
|
||||||
# 固定使用橙子助手 Agent
|
# 固定使用橙子助手 Agent
|
||||||
agent_id = settings.ORANGE_AGENT_ID
|
agent_id = settings.ORANGE_AGENT_ID
|
||||||
if not agent_id:
|
if not agent_id:
|
||||||
@@ -199,7 +222,7 @@ async def _handle_message_async(data):
|
|||||||
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
|
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
|
||||||
learning_enabled=bool(cfg.get("memory_learning", True)),
|
learning_enabled=bool(cfg.get("memory_learning", True)),
|
||||||
),
|
),
|
||||||
user_id=None,
|
user_id=resolved_uid,
|
||||||
memory_scope_id=str(agent.id),
|
memory_scope_id=str(agent.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,21 @@ def _get_sender_open_id(data) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sender_union_id(data) -> Optional[str]:
|
||||||
|
"""从消息事件中提取发送者 union_id(跨应用唯一)。"""
|
||||||
|
try:
|
||||||
|
ev = data.event
|
||||||
|
sender = getattr(ev, "sender", None)
|
||||||
|
if not sender:
|
||||||
|
return None
|
||||||
|
sender_id = getattr(sender, "sender_id", None)
|
||||||
|
if not sender_id:
|
||||||
|
return None
|
||||||
|
return getattr(sender_id, "union_id", None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_chat_type(data) -> str:
|
def _get_chat_type(data) -> str:
|
||||||
"""获取聊天类型。"""
|
"""获取聊天类型。"""
|
||||||
try:
|
try:
|
||||||
@@ -125,6 +140,7 @@ def _make_llm_logger(db, agent_id: Optional[str] = None, user_id: Optional[str]
|
|||||||
async def _handle_message_async(data):
|
async def _handle_message_async(data):
|
||||||
"""异步处理苏瑶消息 — 固定使用苏瑶 Agent。"""
|
"""异步处理苏瑶消息 — 固定使用苏瑶 Agent。"""
|
||||||
open_id = _get_sender_open_id(data)
|
open_id = _get_sender_open_id(data)
|
||||||
|
union_id = _get_sender_union_id(data)
|
||||||
chat_type = _get_chat_type(data)
|
chat_type = _get_chat_type(data)
|
||||||
text = _get_message_text(data)
|
text = _get_message_text(data)
|
||||||
|
|
||||||
@@ -139,11 +155,18 @@ async def _handle_message_async(data):
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
|
from app.services.feishu_open_id_service import resolve_user_and_save
|
||||||
|
|
||||||
db: Optional[Session] = None
|
db: Optional[Session] = None
|
||||||
try:
|
try:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# 自动保存/关联此应用的 open_id(跨应用识别)
|
||||||
|
resolved_uid = resolve_user_and_save(
|
||||||
|
db, app_id=settings.SUYAO_APP_ID or "",
|
||||||
|
open_id=open_id, union_id=union_id,
|
||||||
|
)
|
||||||
|
|
||||||
# 固定使用苏瑶 Agent
|
# 固定使用苏瑶 Agent
|
||||||
agent_id = settings.SUYAO_AGENT_ID
|
agent_id = settings.SUYAO_AGENT_ID
|
||||||
if not agent_id:
|
if not agent_id:
|
||||||
@@ -199,7 +222,7 @@ async def _handle_message_async(data):
|
|||||||
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
|
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
|
||||||
learning_enabled=bool(cfg.get("memory_learning", True)),
|
learning_enabled=bool(cfg.get("memory_learning", True)),
|
||||||
),
|
),
|
||||||
user_id=None,
|
user_id=resolved_uid,
|
||||||
memory_scope_id=str(agent.id),
|
memory_scope_id=str(agent.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ def _get_sender_open_id(data) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sender_union_id(data) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
ev = data.event
|
||||||
|
sender = getattr(ev, "sender", None)
|
||||||
|
if not sender:
|
||||||
|
return None
|
||||||
|
sender_id = getattr(sender, "sender_id", None)
|
||||||
|
if not sender_id:
|
||||||
|
return None
|
||||||
|
return getattr(sender_id, "union_id", None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_chat_type(data) -> str:
|
def _get_chat_type(data) -> str:
|
||||||
try:
|
try:
|
||||||
ev = data.event
|
ev = data.event
|
||||||
@@ -110,6 +124,7 @@ def _make_llm_logger(db, agent_id: Optional[str] = None, user_id: Optional[str]
|
|||||||
|
|
||||||
async def _handle_message_async(data):
|
async def _handle_message_async(data):
|
||||||
open_id = _get_sender_open_id(data)
|
open_id = _get_sender_open_id(data)
|
||||||
|
union_id = _get_sender_union_id(data)
|
||||||
chat_type = _get_chat_type(data)
|
chat_type = _get_chat_type(data)
|
||||||
text = _get_message_text(data)
|
text = _get_message_text(data)
|
||||||
|
|
||||||
@@ -124,11 +139,18 @@ async def _handle_message_async(data):
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
|
from app.services.feishu_open_id_service import resolve_user_and_save
|
||||||
|
|
||||||
db: Optional[Session] = None
|
db: Optional[Session] = None
|
||||||
try:
|
try:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# 自动保存/关联此应用的 open_id(跨应用识别)
|
||||||
|
resolved_uid = resolve_user_and_save(
|
||||||
|
db, app_id=settings.TIANTIAN_APP_ID or "",
|
||||||
|
open_id=open_id, union_id=union_id,
|
||||||
|
)
|
||||||
|
|
||||||
agent_id = settings.TIANTIAN_AGENT_ID
|
agent_id = settings.TIANTIAN_AGENT_ID
|
||||||
if not agent_id:
|
if not agent_id:
|
||||||
_reply_to_feishu(open_id, "甜甜尚未配置,请联系管理员。")
|
_reply_to_feishu(open_id, "甜甜尚未配置,请联系管理员。")
|
||||||
@@ -181,7 +203,7 @@ async def _handle_message_async(data):
|
|||||||
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
|
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
|
||||||
learning_enabled=bool(cfg.get("memory_learning", True)),
|
learning_enabled=bool(cfg.get("memory_learning", True)),
|
||||||
),
|
),
|
||||||
user_id=None,
|
user_id=resolved_uid,
|
||||||
memory_scope_id=str(agent.id),
|
memory_scope_id=str(agent.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user