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

@@ -19,6 +19,13 @@ class AIMemorySource(StrEnum):
HERMES = "hermes"
API = "api"
USER_RULE = "user_rule"
LEGACY_COMPANY = "legacy_company"
class AIMemoryKind(StrEnum):
COMPANY_RULE = "company_rule"
PERSONAL_RULE = "personal_rule"
MEMORY = "memory"
class AIMemoryResponseKey(StrEnum):

View File

@@ -1,19 +1,46 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.constants import ActorValue
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.ai_memory.constants import AIMemoryScope, AIMemorySource, AIMemoryStatus
from app.modules.feishu_users.models import FeishuUser
from app.modules.ai_memory.constants import (
AIMemoryKind,
AIMemoryScope,
AIMemorySource,
AIMemoryStatus,
)
class AIMemoryEntry(Base):
__tablename__ = "ai_memory_entries"
__table_args__ = (
UniqueConstraint(
"owner_id",
"fingerprint",
name="uq_ai_memory_owner_fingerprint",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
fingerprint: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True
)
owner_id: Mapped[int | None] = mapped_column(
ForeignKey("feishu_users.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
owner: Mapped[FeishuUser | None] = relationship()
kind: Mapped[str] = mapped_column(
String(32),
default=AIMemoryKind.MEMORY,
index=True,
)
scope: Mapped[str] = mapped_column(String(64), default=AIMemoryScope.GLOBAL, index=True)
subject: Mapped[str] = mapped_column(String(128), index=True)
content: Mapped[str] = mapped_column(Text)

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
from app.core.security import ApiPrincipal, require_api_key
from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus
from app.modules.ai_memory.schemas import (
AIMemoryRecallRequest,
@@ -22,14 +22,15 @@ def list_memory(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return {
AIMemoryResponseKey.ITEMS: AIMemoryService(db).list_entries(
scope=scope,
subject=subject,
status_filter=status,
limit=limit,
)
}
items = AIMemoryService(db).list_entries(
scope=scope,
subject=subject,
status_filter=status,
limit=limit,
owner_id=None,
)
db.commit()
return {AIMemoryResponseKey.ITEMS: items}
@router.post("/memory/recall")
@@ -44,6 +45,7 @@ def recall_memory(
subject=payload.subject,
limit=payload.limit,
actor=principal.actor,
owner_id=None,
)
return {AIMemoryResponseKey.ITEMS: items}
@@ -62,6 +64,7 @@ def list_rules(
subject=subject,
status_filter=status,
limit=limit,
owner_id=None,
)
}
@@ -72,7 +75,6 @@ def create_rule(
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
require_operations_enabled()
return {
AIMemoryResponseKey.DATA: AIMemoryService(db).create_rule(
content=payload.content,
@@ -81,6 +83,7 @@ def create_rule(
priority=payload.priority,
tags=payload.tags,
actor=principal.actor,
owner_id=None,
)
}
@@ -92,7 +95,6 @@ def update_rule(
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
require_operations_enabled()
return {
AIMemoryResponseKey.DATA: AIMemoryService(db).update_rule(
code=code,
@@ -101,5 +103,20 @@ def update_rule(
tags=payload.tags,
enabled=payload.enabled,
actor=principal.actor,
owner_id=None,
)
}
@router.delete("/rules/{code}")
def delete_rule(
code: str,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
AIMemoryService(db).delete_rule(
code=code,
actor=principal.actor,
owner_id=None,
)
return {AIMemoryResponseKey.DATA: {"code": code, "deleted": True}}

View File

@@ -14,6 +14,8 @@ class AIMemoryRecallRequest(BaseModel):
class AIMemoryRead(BaseModel):
code: str
owner_id: int | None
kind: str
scope: str
subject: str
content: str

View File

@@ -1,9 +1,11 @@
from datetime import datetime, timedelta
from hashlib import sha256
from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import func, or_, select
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config import get_settings
@@ -15,6 +17,7 @@ from app.modules.ai_memory.constants import (
AI_MEMORY_MAX_CONTENT_LENGTH,
AI_MEMORY_MAX_SUMMARY_LENGTH,
AI_MEMORY_MIN_AUTO_WRITE_LENGTH,
AIMemoryKind,
AIMemoryPayloadKey,
AIMemoryScope,
AIMemorySource,
@@ -50,10 +53,15 @@ class AIMemoryService:
subject: str | None = None,
status_filter: str = AIMemoryStatus.ACTIVE,
limit: int = 100,
owner_id: int | None = None,
) -> list[dict[str, Any]]:
self._archive_expired()
stmt = (
select(AIMemoryEntry)
.where(AIMemoryEntry.status == status_filter)
.where(
AIMemoryEntry.status == status_filter,
_owner_filter(owner_id),
)
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.desc())
.limit(bounded_limit(limit))
)
@@ -70,16 +78,20 @@ class AIMemoryService:
subject: str | None = None,
limit: int | None = None,
actor: str = ActorValue.API,
owner_id: int | None = None,
) -> list[dict[str, Any]]:
settings = get_settings()
if not settings.ai_memory_enabled:
return []
limit_value = bounded_limit(limit or settings.ai_memory_recall_limit)
self._archive_expired()
now = utc_now()
stmt = (
select(AIMemoryEntry)
.where(
AIMemoryEntry.status == AIMemoryStatus.ACTIVE,
AIMemoryEntry.kind == AIMemoryKind.MEMORY,
_owner_filter(owner_id),
or_(AIMemoryEntry.expires_at.is_(None), AIMemoryEntry.expires_at > now),
AIMemoryEntry.scope.in_({AIMemoryScope.GLOBAL, scope}),
)
@@ -95,8 +107,6 @@ class AIMemoryService:
)
candidates = list(self.db.execute(stmt).scalars())
items = [item for item in candidates if _matches_query(item, query)]
if not items:
items = candidates[:limit_value]
items = items[:limit_value]
for item in items:
item.last_used_at = now
@@ -126,11 +136,13 @@ class AIMemoryService:
context: dict[str, Any],
answer: str,
actor: str = ActorValue.API,
owner_id: int | None = None,
) -> AIMemoryEntry | None:
settings = get_settings()
if not settings.ai_memory_enabled or not settings.ai_memory_auto_write_enabled:
return None
content = _build_memory_content(prompt, context, answer)
self._archive_expired()
if len(content) < AI_MEMORY_MIN_AUTO_WRITE_LENGTH:
return None
scope = str(context.get(AIMemoryPayloadKey.SCOPE) or AIMemoryText.DEFAULT_SCOPE)
@@ -143,39 +155,60 @@ class AIMemoryService:
},
settings.ai_memory_forbidden_keys,
):
safe_content = str(AIMemoryText.REJECTED_SECRET)
record = self._create_entry(
scope=scope,
subject=subject,
content=str(AIMemoryText.REJECTED_SECRET),
summary=str(AIMemoryText.REJECTED_SECRET),
content=safe_content,
summary=safe_content,
tags=[str(AIMemoryText.AUTO_TAG)],
source=AIMemorySource.AUTO,
importance=0,
status_value=AIMemoryStatus.REJECTED,
actor=actor,
fingerprint=_memory_fingerprint(
owner_id,
scope,
subject,
content,
AIMemoryStatus.REJECTED,
),
owner_id=owner_id,
kind=AIMemoryKind.MEMORY,
expires_at=utc_now()
+ timedelta(days=settings.ai_memory_auto_write_ttl_days),
)
return record
if _contains_blocked_content(content, settings.ai_memory_blocked_content_terms):
safe_content = str(AIMemoryText.REJECTED_SENSITIVE_FACT)
return self._create_entry(
scope=scope,
subject=subject,
content=str(AIMemoryText.REJECTED_SENSITIVE_FACT),
summary=str(AIMemoryText.REJECTED_SENSITIVE_FACT),
content=safe_content,
summary=safe_content,
tags=[str(AIMemoryText.AUTO_TAG)],
source=AIMemorySource.AUTO,
importance=0,
status_value=AIMemoryStatus.REJECTED,
actor=actor,
fingerprint=_memory_fingerprint(
owner_id,
scope,
subject,
content,
AIMemoryStatus.REJECTED,
),
owner_id=owner_id,
kind=AIMemoryKind.MEMORY,
expires_at=utc_now()
+ timedelta(days=settings.ai_memory_auto_write_ttl_days),
)
summary = _truncate(answer, AI_MEMORY_MAX_SUMMARY_LENGTH)
stored_content = _truncate(content, AI_MEMORY_MAX_CONTENT_LENGTH)
record = self._create_entry(
scope=scope,
subject=subject,
content=_truncate(content, AI_MEMORY_MAX_CONTENT_LENGTH),
content=stored_content,
summary=summary,
tags=[str(AIMemoryText.AUTO_TAG)],
source=AIMemorySource.AUTO,
@@ -183,6 +216,15 @@ class AIMemoryService:
status_value=AIMemoryStatus.ACTIVE,
actor=actor,
expires_at=utc_now() + timedelta(days=settings.ai_memory_auto_write_ttl_days),
fingerprint=_memory_fingerprint(
owner_id,
scope,
subject,
stored_content,
AIMemoryStatus.ACTIVE,
),
owner_id=owner_id,
kind=AIMemoryKind.MEMORY,
)
return record
@@ -192,10 +234,19 @@ class AIMemoryService:
subject: str | None = None,
status_filter: str | None = None,
limit: int = 100,
owner_id: int | None = None,
) -> list[dict[str, Any]]:
kind = (
AIMemoryKind.COMPANY_RULE
if owner_id is None
else AIMemoryKind.PERSONAL_RULE
)
stmt = (
select(AIMemoryEntry)
.where(AIMemoryEntry.source == AIMemorySource.USER_RULE)
.where(
AIMemoryEntry.kind == kind,
_owner_filter(owner_id),
)
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.asc())
.limit(bounded_limit(limit))
)
@@ -212,11 +263,19 @@ class AIMemoryService:
scope: str = AIMemoryScope.GLOBAL,
subject: str | None = None,
limit: int = 50,
owner_id: int | None = None,
) -> list[dict[str, Any]]:
self._archive_expired()
kind = (
AIMemoryKind.COMPANY_RULE
if owner_id is None
else AIMemoryKind.PERSONAL_RULE
)
stmt = (
select(AIMemoryEntry)
.where(
AIMemoryEntry.source == AIMemorySource.USER_RULE,
AIMemoryEntry.kind == kind,
_owner_filter(owner_id),
AIMemoryEntry.status == AIMemoryStatus.ACTIVE,
AIMemoryEntry.scope.in_({AIMemoryScope.GLOBAL, scope}),
)
@@ -249,6 +308,7 @@ class AIMemoryService:
priority: int,
tags: list[str] | None,
actor: str,
owner_id: int | None = None,
) -> dict[str, Any]:
self._validate_rule(content, priority)
record = self._create_entry(
@@ -262,6 +322,12 @@ class AIMemoryService:
status_value=AIMemoryStatus.ACTIVE,
actor=actor,
audit_action=AuditAction.AI_RULE_CREATE,
owner_id=owner_id,
kind=(
AIMemoryKind.COMPANY_RULE
if owner_id is None
else AIMemoryKind.PERSONAL_RULE
),
)
return serialize_model(record)
@@ -273,11 +339,18 @@ class AIMemoryService:
tags: list[str] | None,
enabled: bool | None,
actor: str,
owner_id: int | None = None,
) -> dict[str, Any]:
kind = (
AIMemoryKind.COMPANY_RULE
if owner_id is None
else AIMemoryKind.PERSONAL_RULE
)
record = self.db.execute(
select(AIMemoryEntry).where(
AIMemoryEntry.code == code,
AIMemoryEntry.source == AIMemorySource.USER_RULE,
AIMemoryEntry.kind == kind,
_owner_filter(owner_id),
)
).scalar_one_or_none()
if record is None:
@@ -312,6 +385,50 @@ class AIMemoryService:
self.db.refresh(record)
return serialize_model(record)
def delete_rule(
self,
code: str,
actor: str,
owner_id: int | None = None,
) -> None:
"""Delete a rule only within the requested company or personal owner scope."""
kind = (
AIMemoryKind.COMPANY_RULE
if owner_id is None
else AIMemoryKind.PERSONAL_RULE
)
record = self.db.execute(
select(AIMemoryEntry).where(
AIMemoryEntry.code == code,
AIMemoryEntry.kind == kind,
_owner_filter(owner_id),
)
).scalar_one_or_none()
if record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AI rule not found")
self.db.delete(record)
self.audit.record(
AuditLogCreate(
actor=actor,
source=AuditSource.AI_MEMORY,
action=AuditAction.AI_RULE_UPDATE,
target_type=AuditTargetType.AI_MEMORY,
target_id=code,
risk_level=AuditRiskLevel.MEDIUM,
response_payload={"deleted": True},
)
)
self.db.commit()
def delete_owner_entries(self, owner_id: int) -> int:
"""Stage deletion of all personal rules and memories for an owner."""
result = self.db.execute(
delete(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
)
return max(0, int(result.rowcount or 0))
def _validate_rule(self, content: str, priority: int) -> None:
if not AI_USER_RULE_MIN_PRIORITY <= priority <= AI_USER_RULE_MAX_PRIORITY:
raise HTTPException(
@@ -325,11 +442,46 @@ class AIMemoryService:
)
def count_by_status(self) -> dict[str, int]:
self._archive_expired()
rows = self.db.execute(
select(AIMemoryEntry.status, func.count()).group_by(AIMemoryEntry.status)
).all()
return {str(status_value): int(count) for status_value, count in rows}
def _archive_expired(self) -> int:
now = utc_now()
result = self.db.execute(
update(AIMemoryEntry)
.where(
AIMemoryEntry.status.in_(
{
AIMemoryStatus.ACTIVE,
AIMemoryStatus.REJECTED,
}
),
AIMemoryEntry.expires_at.is_not(None),
AIMemoryEntry.expires_at <= now,
)
.values(
status=AIMemoryStatus.ARCHIVED,
updated_at=now,
)
)
archived = max(0, int(result.rowcount or 0))
return archived
def _find_by_fingerprint(
self,
owner_id: int | None,
fingerprint: str,
) -> AIMemoryEntry | None:
return self.db.execute(
select(AIMemoryEntry).where(
AIMemoryEntry.fingerprint == fingerprint,
_owner_filter(owner_id),
)
).scalar_one_or_none()
def _create_entry(
self,
scope: str,
@@ -343,12 +495,23 @@ class AIMemoryService:
actor: str,
audit_action: str = AuditAction.AI_MEMORY_WRITE,
expires_at: datetime | None = None,
fingerprint: str | None = None,
owner_id: int | None = None,
kind: str = AIMemoryKind.MEMORY,
) -> AIMemoryEntry:
if fingerprint:
existing = self._find_by_fingerprint(owner_id, fingerprint)
if existing is not None:
return self._reuse_entry(existing, status_value, expires_at)
record = AIMemoryEntry(
code=(
f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}-"
f"{uuid4().hex[:8]}"
),
fingerprint=fingerprint,
owner_id=owner_id,
kind=kind,
scope=scope,
subject=subject,
content=content,
@@ -360,8 +523,20 @@ class AIMemoryService:
actor=actor,
expires_at=expires_at,
)
self.db.add(record)
self.db.flush()
if fingerprint:
try:
with self.db.begin_nested():
self.db.add(record)
self.db.flush()
except IntegrityError:
existing = self._find_by_fingerprint(owner_id, fingerprint)
if existing is None:
raise
return self._reuse_entry(existing, status_value, expires_at)
else:
self.db.add(record)
self.db.flush()
self.audit.record(
AuditLogCreate(
actor=actor,
@@ -397,6 +572,21 @@ class AIMemoryService:
self.db.refresh(record)
return record
def _reuse_entry(
self,
record: AIMemoryEntry,
status_value: str,
expires_at: datetime | None,
) -> AIMemoryEntry:
if record.status != AIMemoryStatus.ARCHIVED:
return record
record.status = status_value
record.expires_at = expires_at
record.updated_at = utc_now()
self.db.commit()
self.db.refresh(record)
return record
def _build_memory_content(prompt: str, context: dict[str, Any], answer: str) -> str:
context_text = ", ".join(
@@ -427,6 +617,24 @@ def _contains_blocked_content(value: str, blocked_terms: list[str]) -> bool:
return any(term.lower() in lowered for term in blocked_terms if term.strip())
def _memory_fingerprint(
owner_id: int | None,
scope: str,
subject: str,
content: str,
status_value: str,
) -> str:
owner_key = "company" if owner_id is None else f"owner:{owner_id}"
value = "\0".join((owner_key, scope, subject, status_value, content))
return sha256(value.encode("utf-8")).hexdigest()
def _owner_filter(owner_id: int | None):
if owner_id is None:
return AIMemoryEntry.owner_id.is_(None)
return AIMemoryEntry.owner_id == owner_id
def _matches_query(entry: AIMemoryEntry, query: str) -> bool:
query_text = query.lower().strip()
if not query_text: