feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
This commit is contained in:
2026-07-27 08:02:17 +08:00
parent db751f03b4
commit d7db84571d
148 changed files with 17110 additions and 765 deletions

View File

@@ -0,0 +1,300 @@
import re
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.application.feishu.delivery import send_text_if_configured
from app.application.feishu.results import command_result
from app.modules.audit.constants import AuditRiskLevel, AuditSource
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType
from app.modules.feishu.service import FeishuService
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.personalization.constants import PreferenceCategory
from app.modules.personalization.services import ConversationService, PreferenceService
PERSONALIZATION_TITLE = "个人设置"
PREFERENCE_HELP = (
"偏好指令格式:\n"
"记住偏好 语言:中文\n"
"记住偏好 语气:简洁\n"
"记住偏好 详略:详细\n"
"关注主题:人工智能\n"
"我的偏好\n"
"删除偏好 <偏好编号>"
)
_CATEGORY_LABELS = {
"语言": PreferenceCategory.LANGUAGE,
"语气": PreferenceCategory.TONE,
"详略": PreferenceCategory.DETAIL,
"主题": PreferenceCategory.TOPIC,
"兴趣": PreferenceCategory.INTEREST,
}
_CATEGORY_NAMES = {
PreferenceCategory.LANGUAGE: "语言",
PreferenceCategory.TONE: "语气",
PreferenceCategory.DETAIL: "详略",
PreferenceCategory.TOPIC: "关注主题",
PreferenceCategory.INTEREST: "兴趣",
}
_SET_PATTERN = re.compile(
r"^记住偏好\s+(语言|语气|详略|主题|兴趣)\s*[:]\s*(.+)$"
)
_TOPIC_PATTERN = re.compile(r"^关注主题\s*[:]?\s*(.+)$")
_DELETE_PATTERN = re.compile(
r"^删除偏好\s+(PREF-[A-Za-z0-9-]+)$",
re.IGNORECASE,
)
_LIST_COMMANDS = {"我的偏好", "查看偏好", "我的兴趣", "查看兴趣"}
_HELP_COMMANDS = {"帮助", "使用帮助", "命令帮助"}
_COMMAND_PREFIXES = (
"记住偏好",
"关注主题",
"我的偏好",
"查看偏好",
"我的兴趣",
"查看兴趣",
"删除偏好",
"重置对话",
*_HELP_COMMANDS,
)
def handle_personalization_command(
db: Session,
feishu: FeishuService,
command_text: str,
principal: FeishuPrincipal,
auto_reply: bool,
) -> dict[str, Any] | None:
"""Handle owner-scoped preferences, interests, help, and conversation reset."""
text = command_text.strip()
if not text.startswith(_COMMAND_PREFIXES):
return None
principal.require_active()
if text in _HELP_COMMANDS:
return _result(
feishu,
FeishuCommandName.HELP,
_help_content(principal),
principal,
auto_reply,
)
if text == "重置对话":
chat_type, chat_key = _conversation_key(principal)
reset = ConversationService(db).reset(
principal.owner_id,
chat_type,
chat_key,
)
_audit(
db,
principal,
action="personalization.conversation.reset",
target_type="ai-conversation",
response={"reset": reset},
)
content = "当前会话已重置。" if reset else "当前会话没有可清除的历史。"
return _result(
feishu,
FeishuCommandName.CONVERSATION_RESET,
content,
principal,
auto_reply,
)
service = PreferenceService(db)
set_match = _SET_PATTERN.fullmatch(text)
topic_match = _TOPIC_PATTERN.fullmatch(text)
delete_match = _DELETE_PATTERN.fullmatch(text)
try:
if set_match:
category = _CATEGORY_LABELS[set_match.group(1)]
record = service.upsert(
principal.owner_id,
category,
set_match.group(2),
)
_audit_preference(db, principal, "upsert", record)
content = (
"偏好已保存。\n"
f"编号:{record['code']}\n"
f"类别:{_category_name(record['category'])}\n"
f"内容:{record['value']}"
)
command = FeishuCommandName.PREFERENCE_SET
elif topic_match:
record = service.upsert(
principal.owner_id,
PreferenceCategory.TOPIC,
topic_match.group(1),
)
_audit_preference(db, principal, "upsert", record)
content = (
"关注主题已保存。\n"
f"编号:{record['code']}\n"
f"主题:{record['value']}"
)
command = FeishuCommandName.PREFERENCE_SET
elif text in _LIST_COMMANDS:
records = service.list_preferences(principal.owner_id)
_audit(
db,
principal,
action="personalization.preference.list",
target_type="user-preference",
response={"count": len(records)},
)
content = _preference_list(records)
command = FeishuCommandName.PREFERENCE_LIST
elif delete_match:
code = delete_match.group(1)
service.delete(principal.owner_id, code)
_audit(
db,
principal,
action="personalization.preference.delete",
target_type="user-preference",
target_id=code,
response={"deleted": True},
)
content = "偏好已删除。"
command = FeishuCommandName.PREFERENCE_DELETE
else:
content = PREFERENCE_HELP
command = FeishuCommandName.PREFERENCE_SET
except HTTPException as exc:
db.rollback()
if exc.status_code == 404:
content = "没有找到该偏好,请先发送“我的偏好”确认编号。"
elif "Sensitive preference" in str(exc.detail):
content = "该内容可能涉及敏感个人信息或密钥,已拒绝保存。"
else:
content = f"偏好指令未执行,请检查格式。\n\n{PREFERENCE_HELP}"
command = (
FeishuCommandName.PREFERENCE_DELETE
if delete_match
else FeishuCommandName.PREFERENCE_SET
)
return _result(feishu, command, content, principal, auto_reply)
def _preference_list(records: list[dict[str, Any]]) -> str:
if not records:
return "当前没有已保存的偏好或兴趣。\n\n" + PREFERENCE_HELP
lines = ["我的偏好与兴趣:"]
for record in records:
lines.append(
f"{record['code']}{_category_name(record['category'])}"
f"{record['value']}"
)
return "\n".join(lines)
def _help_content(principal: FeishuPrincipal) -> str:
lines = [
"你可以使用:",
"问 <问题>",
"学习规则:<个人规则>;查看/修改/启用/停用/删除规则",
"记住偏好、关注主题、我的偏好、删除偏好",
"加入自选 <股票代码>;查看自选",
"订阅 <自然语言时间><提示词>;我的订阅、暂停、恢复、退订",
"设置时区、设置/关闭安静时段",
"重置对话、我的数据、忘记我",
]
if principal.is_admin:
lines.extend(
[
"",
"管理员还可以使用:",
"日报、周报、财务、风险和考勤查询",
"学习/查看/修改/启停/删除公司规则",
"在当前群创建群订阅",
"通过真实 @用户 管理角色和状态",
]
)
return "\n".join(lines)
def _conversation_key(principal: FeishuPrincipal) -> tuple[str, str]:
chat_type = principal.chat_type or "p2p"
is_group = chat_type in {"group", "group_chat"}
chat_key = principal.chat_id if is_group else (principal.chat_id or principal.open_id)
if not chat_key:
raise HTTPException(status_code=422, detail="Missing Feishu chat identity")
return chat_type, chat_key
def _audit_preference(
db: Session,
principal: FeishuPrincipal,
action: str,
record: dict[str, Any],
) -> None:
_audit(
db,
principal,
action=f"personalization.preference.{action}",
target_type="user-preference",
target_id=str(record["code"]),
response={"category": record["category"]},
)
def _audit(
db: Session,
principal: FeishuPrincipal,
*,
action: str,
target_type: str,
target_id: str | None = None,
response: dict[str, Any] | None = None,
) -> None:
AuditService(db).record(
AuditLogCreate(
actor=principal.user_code,
source=AuditSource.FEISHU,
action=action,
target_type=target_type,
target_id=target_id,
risk_level=AuditRiskLevel.LOW,
response_payload=response,
)
)
db.commit()
def _category_name(value: str) -> str:
try:
return _CATEGORY_NAMES[PreferenceCategory(value)]
except ValueError:
return value
def _result(
feishu: FeishuService,
command: FeishuCommandName,
content: str,
principal: FeishuPrincipal,
auto_reply: bool,
) -> dict[str, Any]:
response = (
send_text_if_configured(
feishu,
principal.chat_id,
content,
principal.user_code,
)
if auto_reply
else None
)
return command_result(
command,
FeishuReplyType.TEXT,
PERSONALIZATION_TITLE,
content,
response,
)