```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
76
app/application/delivery/subscriptions.py
Normal file
76
app/application/delivery/subscriptions.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from typing import Any
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.ai_agent.constants import AIResponseKey
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.subscriptions.services import (
|
||||
DeliveryGenerationRequest,
|
||||
DeliverySendRequest,
|
||||
DeliveryService,
|
||||
RetryableDeliveryError,
|
||||
SubscriptionScanner,
|
||||
)
|
||||
|
||||
|
||||
class AISubscriptionGenerator:
|
||||
"""Generate side-effect-free subscription content through the configured AI."""
|
||||
|
||||
def __init__(self, ai: AIService):
|
||||
self.ai = ai
|
||||
|
||||
def generate(self, request: DeliveryGenerationRequest) -> str:
|
||||
result = self.ai.generate_scheduled(
|
||||
request.prompt,
|
||||
owner_id=request.owner_id,
|
||||
group=request.use_company_rules and not request.use_personal_context,
|
||||
actor=ActorValue.SCHEDULER,
|
||||
)
|
||||
if not result.get(AIResponseKey.OK):
|
||||
raise RetryableDeliveryError("AI provider is unavailable")
|
||||
return str(result[AIResponseKey.ANSWER])
|
||||
|
||||
|
||||
class FeishuSubscriptionSender:
|
||||
"""Send a delivery with the stable Feishu UUID supplied by durable state."""
|
||||
|
||||
def __init__(self, feishu: FeishuService):
|
||||
self.feishu = feishu
|
||||
|
||||
def send(self, request: DeliverySendRequest) -> dict[str, Any]:
|
||||
return self.feishu.send_text(
|
||||
request.text,
|
||||
receive_id=request.receive_id,
|
||||
receive_id_type=request.receive_id_type,
|
||||
actor=ActorValue.SCHEDULER,
|
||||
uuid=request.uuid,
|
||||
tenant_key=request.tenant_key,
|
||||
)
|
||||
|
||||
|
||||
def run_subscription_cycle(
|
||||
*,
|
||||
actor: str = ActorValue.SCHEDULER,
|
||||
) -> dict[str, Any]:
|
||||
"""Materialize due windows and process pending/retry deliveries."""
|
||||
|
||||
_ = actor
|
||||
db = SessionLocal()
|
||||
try:
|
||||
created = SubscriptionScanner(db).scan_due()
|
||||
delivery_service = DeliveryService(
|
||||
db,
|
||||
generator=AISubscriptionGenerator(AIService(db)),
|
||||
sender=FeishuSubscriptionSender(FeishuService(db)),
|
||||
)
|
||||
processed = delivery_service.process_due()
|
||||
return {
|
||||
"created": [item.code for item in created],
|
||||
"processed": [
|
||||
{"code": item.code, "status": item.status}
|
||||
for item in processed
|
||||
],
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -3,9 +3,12 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import or_, select, update
|
||||
|
||||
from app.application.events.handlers import EventHandlerMixin
|
||||
from app.application.events.handlers import (
|
||||
EventHandlerMixin,
|
||||
UnsupportedEventTypeError,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.http.pagination import bounded_limit
|
||||
@@ -44,12 +47,11 @@ class EventDispatchService(EventHandlerMixin):
|
||||
worker_id: str | None = None,
|
||||
preclaimed: bool = False,
|
||||
) -> DomainEvent:
|
||||
lock_owner = worker_id or f"api:{uuid4().hex}"
|
||||
record = (
|
||||
self.get_event(event_id)
|
||||
if preclaimed
|
||||
else self._claim_event(event_id, lock_owner)
|
||||
)
|
||||
if preclaimed:
|
||||
lock_owner = worker_id or ""
|
||||
else:
|
||||
lock_owner = f"{worker_id or 'api'}:{uuid4().hex}"
|
||||
record = self.get_event(event_id) if preclaimed else self._claim_event(event_id, lock_owner)
|
||||
if record.status == EventStatus.PROCESSED:
|
||||
return record
|
||||
if preclaimed and record.locked_by != lock_owner:
|
||||
@@ -63,30 +65,31 @@ class EventDispatchService(EventHandlerMixin):
|
||||
try:
|
||||
self._handle_event(record)
|
||||
except Exception as exc:
|
||||
retryable = record.attempts < self._max_attempts(record)
|
||||
record.status = EventStatus.PENDING if retryable else EventStatus.FAILED
|
||||
record.last_error = str(exc)
|
||||
record.next_attempt_at = (
|
||||
utc_now() + timedelta(seconds=settings.event_dispatch_retry_delay_seconds)
|
||||
if retryable
|
||||
else None
|
||||
self.db.rollback()
|
||||
record = self.get_event(event_id)
|
||||
retryable = not isinstance(
|
||||
exc, UnsupportedEventTypeError
|
||||
) and record.attempts < self._max_attempts(record)
|
||||
return self._finalize_event(
|
||||
event_id,
|
||||
lock_owner,
|
||||
status_value=(EventStatus.PENDING if retryable else EventStatus.FAILED),
|
||||
last_error=f"{type(exc).__name__}: {exc}"[:2000],
|
||||
next_attempt_at=(
|
||||
utc_now() + timedelta(seconds=settings.event_dispatch_retry_delay_seconds)
|
||||
if retryable
|
||||
else None
|
||||
),
|
||||
processed_at=None,
|
||||
)
|
||||
record.locked_by = None
|
||||
record.locked_until = None
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self._audit_dispatch(record)
|
||||
return record
|
||||
record.status = EventStatus.PROCESSED
|
||||
record.last_error = None
|
||||
record.processed_at = utc_now()
|
||||
record.next_attempt_at = None
|
||||
record.locked_by = None
|
||||
record.locked_until = None
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self._audit_dispatch(record)
|
||||
return record
|
||||
return self._finalize_event(
|
||||
event_id,
|
||||
lock_owner,
|
||||
status_value=EventStatus.PROCESSED,
|
||||
last_error=None,
|
||||
next_attempt_at=None,
|
||||
processed_at=utc_now(),
|
||||
)
|
||||
|
||||
def dispatch_pending(
|
||||
self,
|
||||
@@ -95,7 +98,7 @@ class EventDispatchService(EventHandlerMixin):
|
||||
) -> list[dict[str, Any]]:
|
||||
now = utc_now()
|
||||
stmt = (
|
||||
select(DomainEvent)
|
||||
select(DomainEvent.event_id)
|
||||
.where(
|
||||
DomainEvent.status == EventStatus.PENDING,
|
||||
or_(
|
||||
@@ -113,38 +116,51 @@ class EventDispatchService(EventHandlerMixin):
|
||||
)
|
||||
.order_by(DomainEvent.id.asc())
|
||||
.limit(bounded_limit(limit))
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
records = list(self.db.execute(stmt).scalars())
|
||||
lock_owner = worker_id or f"worker:{uuid4().hex}"
|
||||
locked_until = now + timedelta(
|
||||
seconds=get_settings().event_dispatch_lock_seconds
|
||||
)
|
||||
for record in records:
|
||||
record.locked_by = lock_owner
|
||||
record.locked_until = locked_until
|
||||
record.attempts += 1
|
||||
self.db.commit()
|
||||
return [
|
||||
_serialize_event(
|
||||
self.dispatch_event(
|
||||
record.event_id,
|
||||
event_ids = list(self.db.execute(stmt).scalars())
|
||||
lock_owner = f"{worker_id or 'worker'}:{uuid4().hex}"
|
||||
dispatched: list[dict[str, Any]] = []
|
||||
for event_id in event_ids:
|
||||
try:
|
||||
record = self.dispatch_event(
|
||||
event_id,
|
||||
worker_id=lock_owner,
|
||||
preclaimed=True,
|
||||
)
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == status.HTTP_409_CONFLICT:
|
||||
continue
|
||||
raise
|
||||
dispatched.append(_serialize_event(record))
|
||||
return dispatched
|
||||
|
||||
def retry_event(self, event_id: str, actor: str = ActorValue.API) -> DomainEvent:
|
||||
record = self.get_event(event_id)
|
||||
record = self.db.execute(
|
||||
select(DomainEvent).where(DomainEvent.event_id == event_id).with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=EventErrorDetail.EVENT_NOT_FOUND,
|
||||
)
|
||||
now = utc_now()
|
||||
if (
|
||||
record.locked_by is not None
|
||||
and record.locked_until is not None
|
||||
and record.locked_until > now
|
||||
):
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=EventErrorDetail.EVENT_LOCKED,
|
||||
)
|
||||
if record.status == EventStatus.PROCESSED:
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=EventErrorDetail.EVENT_NOT_RETRYABLE,
|
||||
)
|
||||
record.status = EventStatus.PENDING
|
||||
record.actor = actor
|
||||
record.attempts = 0
|
||||
record.max_attempts = record.max_attempts or get_settings().event_dispatch_max_attempts
|
||||
record.last_error = None
|
||||
@@ -155,8 +171,48 @@ class EventDispatchService(EventHandlerMixin):
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def _audit_dispatch(self, record: DomainEvent) -> None:
|
||||
AuditService(self.db).log(
|
||||
def _finalize_event(
|
||||
self,
|
||||
event_id: str,
|
||||
lock_owner: str,
|
||||
*,
|
||||
status_value: str,
|
||||
last_error: str | None,
|
||||
next_attempt_at: Any,
|
||||
processed_at: Any,
|
||||
) -> DomainEvent:
|
||||
result = self.db.execute(
|
||||
update(DomainEvent)
|
||||
.where(
|
||||
DomainEvent.event_id == event_id,
|
||||
DomainEvent.locked_by == lock_owner,
|
||||
DomainEvent.status == EventStatus.PENDING,
|
||||
)
|
||||
.values(
|
||||
status=status_value,
|
||||
last_error=last_error,
|
||||
next_attempt_at=next_attempt_at,
|
||||
processed_at=processed_at,
|
||||
locked_by=None,
|
||||
locked_until=None,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=EventErrorDetail.EVENT_LOCKED,
|
||||
)
|
||||
self.db.expire_all()
|
||||
record = self.get_event(event_id)
|
||||
self._stage_dispatch_audit(record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def _stage_dispatch_audit(self, record: DomainEvent) -> None:
|
||||
AuditService(self.db).record(
|
||||
AuditLogCreate(
|
||||
actor=record.actor,
|
||||
source=AuditSource.EVENTS,
|
||||
@@ -198,9 +254,7 @@ class EventDispatchService(EventHandlerMixin):
|
||||
detail=EventErrorDetail.EVENT_LOCKED,
|
||||
)
|
||||
record.locked_by = lock_owner
|
||||
record.locked_until = now + timedelta(
|
||||
seconds=get_settings().event_dispatch_lock_seconds
|
||||
)
|
||||
record.locked_until = now + timedelta(seconds=get_settings().event_dispatch_lock_seconds)
|
||||
record.status = EventStatus.PENDING
|
||||
record.attempts += 1
|
||||
self.db.commit()
|
||||
|
||||
@@ -6,6 +6,10 @@ from app.modules.events.constants import (
|
||||
from app.modules.events.models import DomainEvent
|
||||
|
||||
|
||||
class UnsupportedEventTypeError(ValueError):
|
||||
"""Raised when no application handler is registered for an event type."""
|
||||
|
||||
|
||||
class EventHandlerMixin:
|
||||
def _handle_event(self, record: DomainEvent) -> None:
|
||||
if record.event_type == EventType.RISK_ACTION_RECORDED:
|
||||
@@ -30,6 +34,9 @@ class EventHandlerMixin:
|
||||
if record.event_type == EventType.ENTERPRISE_ANALYTICS_GENERATED:
|
||||
self._handle_enterprise_analytics_event(record)
|
||||
return
|
||||
raise UnsupportedEventTypeError(
|
||||
f"Unsupported domain event type: {record.event_type}"
|
||||
)
|
||||
|
||||
def _handle_risk_action(self, record: DomainEvent) -> None:
|
||||
from app.modules.risk.constants import RiskEventActionValue
|
||||
@@ -53,6 +60,7 @@ class EventHandlerMixin:
|
||||
actor=record.actor,
|
||||
payload=payload,
|
||||
commit=False,
|
||||
source_event_id=record.event_id,
|
||||
)
|
||||
|
||||
def _handle_report_event(self, record: DomainEvent) -> None:
|
||||
@@ -125,4 +133,5 @@ class EventHandlerMixin:
|
||||
actor=record.actor,
|
||||
payload=record.payload or {},
|
||||
commit=False,
|
||||
source_event_id=record.event_id,
|
||||
)
|
||||
|
||||
@@ -6,15 +6,23 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.feishu.delivery import send_card_if_configured, send_text_if_configured
|
||||
from app.application.feishu.handlers import (
|
||||
handle_admin_command,
|
||||
handle_finance_command,
|
||||
handle_market_command,
|
||||
handle_personal_data_command,
|
||||
handle_personalization_command,
|
||||
handle_rule_command,
|
||||
handle_subscription_command,
|
||||
is_admin_command,
|
||||
is_company_rule_command,
|
||||
)
|
||||
from app.application.feishu.results import command_result
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.ai_agent.constants import AIResponseKey
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_AI_REPLY_TITLE,
|
||||
FEISHU_MENTION_PATTERN,
|
||||
@@ -25,6 +33,12 @@ from app.modules.feishu.constants import (
|
||||
FeishuReplyType,
|
||||
)
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.feishu_users.constants import (
|
||||
FEISHU_USER_TARGET_TYPE,
|
||||
FeishuCapability,
|
||||
FeishuUserAuditAction,
|
||||
)
|
||||
from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal
|
||||
from app.modules.reports.constants import ReportResponseKey
|
||||
from app.modules.reports.services import ReportService
|
||||
|
||||
@@ -34,6 +48,18 @@ ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance")
|
||||
RISK_KEYWORDS = ("风险", "预警", "risk")
|
||||
AI_COMMAND_PREFIXES = ("问 ", "ai ", "AI ", "/ask ")
|
||||
DEFAULT_AI_PROMPT = "请说明你能做什么。"
|
||||
PERMISSION_DENIED_TITLE = "权限不足"
|
||||
COMPANY_RULE_COMMAND_PREFIXES = (
|
||||
"学习公司规则",
|
||||
"查看公司规则",
|
||||
"修改公司规则",
|
||||
"启用公司规则",
|
||||
"停用公司规则",
|
||||
"删除公司规则",
|
||||
"学习公司市场规则",
|
||||
"查看公司市场规则",
|
||||
)
|
||||
FINANCE_COMMAND_PREFIXES = ("资金需求", "未来30天资金需求", "项目资金 ")
|
||||
|
||||
|
||||
def _parse_content_text(content: Any) -> str:
|
||||
@@ -70,12 +96,14 @@ class FeishuCommandService:
|
||||
self.feishu = FeishuService(db)
|
||||
|
||||
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
if not message:
|
||||
return None
|
||||
text = _clean_command_text(_parse_content_text(message.get(FeishuPayloadKey.CONTENT)))
|
||||
if not text:
|
||||
raw_text = _parse_content_text(message.get(FeishuPayloadKey.CONTENT))
|
||||
command_text = _clean_command_text(raw_text)
|
||||
if not command_text:
|
||||
return None
|
||||
sender = event.get(FeishuPayloadKey.SENDER) or {}
|
||||
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
|
||||
@@ -85,9 +113,18 @@ class FeishuCommandService:
|
||||
or ActorValue.FEISHU
|
||||
)
|
||||
return {
|
||||
FeishuCommandKey.TEXT: text,
|
||||
FeishuCommandKey.TEXT: (
|
||||
raw_text
|
||||
if get_settings().feishu_user_features_enabled
|
||||
else command_text
|
||||
),
|
||||
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
|
||||
FeishuCommandKey.CHAT_TYPE: message.get(FeishuPayloadKey.CHAT_TYPE),
|
||||
FeishuCommandKey.ACTOR: actor,
|
||||
FeishuCommandKey.MENTIONS: _parse_mentions(
|
||||
message.get(FeishuPayloadKey.MENTIONS),
|
||||
str(header.get(FeishuPayloadKey.TENANT_KEY) or ""),
|
||||
),
|
||||
}
|
||||
|
||||
def handle_text(
|
||||
@@ -96,14 +133,99 @@ class FeishuCommandService:
|
||||
chat_id: str | None = None,
|
||||
actor: str = ActorValue.FEISHU,
|
||||
auto_reply: bool = True,
|
||||
principal: FeishuPrincipal | None = None,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if tenant_key is not None:
|
||||
self.feishu.set_tenant_key(tenant_key)
|
||||
raw_text = text or ""
|
||||
command_text = _clean_command_text(text)
|
||||
lowered = command_text.lower()
|
||||
if get_settings().feishu_user_features_enabled:
|
||||
if principal is None:
|
||||
return self._permission_denied(
|
||||
chat_id=chat_id,
|
||||
actor=actor,
|
||||
auto_reply=False,
|
||||
principal=None,
|
||||
reason="missing_verified_identity",
|
||||
content="无法确认飞书账号身份,已拒绝执行该命令。",
|
||||
)
|
||||
self.feishu.set_tenant_key(principal.tenant_key)
|
||||
chat_id = principal.chat_id or chat_id
|
||||
actor = principal.user_code
|
||||
if not principal.is_active:
|
||||
return self._permission_denied(
|
||||
chat_id=chat_id,
|
||||
actor=actor,
|
||||
auto_reply=auto_reply,
|
||||
principal=principal,
|
||||
reason="disabled_user",
|
||||
content="当前飞书账号已停用,请联系管理员。",
|
||||
)
|
||||
required_capability = _required_capability(command_text)
|
||||
if (
|
||||
required_capability is not None
|
||||
and not principal.has_capability(required_capability)
|
||||
):
|
||||
return self._permission_denied(
|
||||
chat_id=chat_id,
|
||||
actor=actor,
|
||||
auto_reply=auto_reply,
|
||||
principal=principal,
|
||||
reason=f"missing_capability:{required_capability}",
|
||||
content="当前飞书账号无权使用该公司级功能。",
|
||||
)
|
||||
admin_result = handle_admin_command(
|
||||
self.db,
|
||||
self.feishu,
|
||||
raw_text=raw_text,
|
||||
command_text=command_text,
|
||||
principal=principal,
|
||||
auto_reply=auto_reply,
|
||||
)
|
||||
if admin_result is not None:
|
||||
return admin_result
|
||||
for handler in (
|
||||
handle_subscription_command,
|
||||
handle_personal_data_command,
|
||||
handle_personalization_command,
|
||||
):
|
||||
result = handler(
|
||||
self.db,
|
||||
self.feishu,
|
||||
command_text,
|
||||
principal,
|
||||
auto_reply,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
rule_result = handle_rule_command(
|
||||
self.db,
|
||||
self.feishu,
|
||||
command_text,
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
principal=principal,
|
||||
)
|
||||
if rule_result is not None:
|
||||
return rule_result
|
||||
market_result = handle_market_command(
|
||||
self.db,
|
||||
self.feishu,
|
||||
command_text,
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
principal=principal,
|
||||
)
|
||||
if market_result is not None:
|
||||
return market_result
|
||||
|
||||
for handler in (
|
||||
handle_rule_command,
|
||||
handle_finance_command,
|
||||
handle_market_command,
|
||||
):
|
||||
result = handler(
|
||||
self.db,
|
||||
@@ -115,11 +237,65 @@ class FeishuCommandService:
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
if not get_settings().feishu_user_features_enabled:
|
||||
for handler in (handle_rule_command, handle_market_command):
|
||||
result = handler(
|
||||
self.db,
|
||||
self.feishu,
|
||||
command_text,
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
report_result = self._handle_report_command(command_text, chat_id, actor, auto_reply)
|
||||
if report_result is not None:
|
||||
return report_result
|
||||
return self._handle_ai_command(command_text, lowered, chat_id, actor, auto_reply)
|
||||
return self._handle_ai_command(
|
||||
command_text,
|
||||
lowered,
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
principal=principal,
|
||||
)
|
||||
|
||||
def _permission_denied(
|
||||
self,
|
||||
*,
|
||||
chat_id: str | None,
|
||||
actor: str,
|
||||
auto_reply: bool,
|
||||
principal: FeishuPrincipal | None,
|
||||
reason: str,
|
||||
content: str,
|
||||
) -> dict[str, Any]:
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FeishuUserAuditAction.PERMISSION_DENIED,
|
||||
target_type=FEISHU_USER_TARGET_TYPE,
|
||||
target_id=principal.user_code if principal else None,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
response_payload={"result": "denied", "reason": reason},
|
||||
status="denied",
|
||||
)
|
||||
)
|
||||
response = (
|
||||
send_text_if_configured(self.feishu, chat_id, content, actor)
|
||||
if auto_reply
|
||||
else None
|
||||
)
|
||||
return command_result(
|
||||
FeishuCommandName.PERMISSION_DENIED,
|
||||
FeishuReplyType.TEXT,
|
||||
PERMISSION_DENIED_TITLE,
|
||||
content,
|
||||
response,
|
||||
)
|
||||
|
||||
def _handle_report_command(
|
||||
self,
|
||||
@@ -170,6 +346,7 @@ class FeishuCommandService:
|
||||
chat_id: str | None,
|
||||
actor: str,
|
||||
auto_reply: bool,
|
||||
principal: FeishuPrincipal | None = None,
|
||||
) -> dict[str, Any]:
|
||||
prompt = command_text
|
||||
for prefix in AI_COMMAND_PREFIXES:
|
||||
@@ -178,12 +355,23 @@ class FeishuCommandService:
|
||||
break
|
||||
if not prompt:
|
||||
prompt = DEFAULT_AI_PROMPT
|
||||
ai_result = AIService(self.db).ask(
|
||||
prompt,
|
||||
context={},
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
)
|
||||
if get_settings().feishu_user_features_enabled and principal is not None:
|
||||
chat_type, chat_key = _principal_chat_context(principal)
|
||||
ai_result = AIService(self.db).ask_personalized(
|
||||
principal.owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
prompt,
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
)
|
||||
else:
|
||||
ai_result = AIService(self.db).ask(
|
||||
prompt,
|
||||
context={},
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
)
|
||||
content = ai_result[AIResponseKey.ANSWER]
|
||||
is_explicit_ai = any(
|
||||
command_text.startswith(prefix) or lowered.startswith(prefix)
|
||||
@@ -199,3 +387,76 @@ class FeishuCommandService:
|
||||
content,
|
||||
response,
|
||||
)
|
||||
|
||||
|
||||
def _parse_mentions(value: Any, tenant_key: str) -> tuple[FeishuMention, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
mentions: list[FeishuMention] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mention_id = item.get(FeishuPayloadKey.ID) or {}
|
||||
if not isinstance(mention_id, dict):
|
||||
mention_id = {}
|
||||
mentions.append(
|
||||
FeishuMention(
|
||||
key=_optional_text(item.get(FeishuPayloadKey.KEY)),
|
||||
name=_optional_text(item.get(FeishuPayloadKey.NAME)),
|
||||
tenant_key=(
|
||||
_optional_text(item.get(FeishuPayloadKey.TENANT_KEY))
|
||||
or tenant_key
|
||||
or None
|
||||
),
|
||||
open_id=_optional_text(
|
||||
mention_id.get(FeishuPayloadKey.OPEN_ID)
|
||||
or item.get(FeishuPayloadKey.OPEN_ID)
|
||||
),
|
||||
union_id=_optional_text(
|
||||
mention_id.get(FeishuPayloadKey.UNION_ID)
|
||||
or item.get(FeishuPayloadKey.UNION_ID)
|
||||
),
|
||||
user_id=_optional_text(
|
||||
mention_id.get(FeishuPayloadKey.USER_ID)
|
||||
or item.get(FeishuPayloadKey.USER_ID)
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(mentions)
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _required_capability(command_text: str) -> FeishuCapability | None:
|
||||
if is_admin_command(command_text):
|
||||
return FeishuCapability.USER_ADMINISTRATION
|
||||
if (
|
||||
command_text.startswith(COMPANY_RULE_COMMAND_PREFIXES)
|
||||
or is_company_rule_command(command_text)
|
||||
):
|
||||
return FeishuCapability.COMPANY_RULES
|
||||
if command_text.startswith(FINANCE_COMMAND_PREFIXES):
|
||||
return FeishuCapability.COMPANY_REPORTS
|
||||
if any(
|
||||
keyword in command_text
|
||||
for keyword in (
|
||||
*DAILY_REPORT_KEYWORDS,
|
||||
*PROJECT_WEEKLY_KEYWORDS,
|
||||
*ATTENDANCE_KEYWORDS,
|
||||
*RISK_KEYWORDS,
|
||||
)
|
||||
):
|
||||
return FeishuCapability.COMPANY_REPORTS
|
||||
return None
|
||||
|
||||
|
||||
def _principal_chat_context(principal: FeishuPrincipal) -> tuple[str, str]:
|
||||
chat_type = principal.chat_type or "p2p"
|
||||
if chat_type in {"group", "group_chat"}:
|
||||
if not principal.chat_id:
|
||||
raise ValueError("Verified group chat is missing chat_id")
|
||||
return chat_type, principal.chat_id
|
||||
return chat_type, principal.chat_id or principal.open_id
|
||||
|
||||
@@ -9,13 +9,20 @@ def send_text_if_configured(
|
||||
chat_id: str | None,
|
||||
text: str,
|
||||
actor: str,
|
||||
*,
|
||||
record_audit: bool = True,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Send a text reply only when Feishu credentials are configured."""
|
||||
|
||||
settings = get_settings()
|
||||
if not (settings.feishu_app_id and settings.feishu_app_secret):
|
||||
return None
|
||||
return feishu.send_text(text, receive_id=chat_id, actor=actor)
|
||||
return feishu.send_text(
|
||||
text,
|
||||
receive_id=chat_id,
|
||||
actor=actor,
|
||||
record_audit=record_audit,
|
||||
)
|
||||
|
||||
|
||||
def send_card_if_configured(
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.feishu.commands import FeishuCommandService
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.audit.constants import AuditAction, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.app_tickets import (
|
||||
APP_TICKET_EVENT_TYPE,
|
||||
APP_TICKET_PAYLOAD_KEY,
|
||||
FeishuAppTicketService,
|
||||
)
|
||||
from app.modules.feishu.constants import (
|
||||
FeishuCommandKey,
|
||||
FeishuEventReceiptKey,
|
||||
@@ -16,6 +24,8 @@ from app.modules.feishu.constants import (
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal
|
||||
from app.modules.feishu_users.services import FeishuIdentityService
|
||||
|
||||
FEISHU_EVENT_ACTIONS = {
|
||||
FeishuEventSource.WEBHOOK: AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||
@@ -38,10 +48,33 @@ class FeishuEventService:
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
self.feishu.verify_event(payload)
|
||||
return self._handle_verified_event(payload, source, auto_reply)
|
||||
|
||||
def _handle_verified_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: str | FeishuEventSource,
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle an event after an HTTP verifier or the Feishu SDK accepted it."""
|
||||
|
||||
challenge = payload.get(FeishuPayloadKey.CHALLENGE)
|
||||
if challenge:
|
||||
return {FeishuResponseKey.CHALLENGE: challenge}
|
||||
source_value = _normalize_source(source)
|
||||
if _event_type(payload) == APP_TICKET_EVENT_TYPE:
|
||||
return self._handle_app_ticket_event(payload, source_value)
|
||||
user_features_enabled = get_settings().feishu_user_features_enabled
|
||||
command = (
|
||||
self.commands.extract_event_command(payload)
|
||||
if user_features_enabled
|
||||
else None
|
||||
)
|
||||
principal = (
|
||||
self._resolve_principal(payload, command)
|
||||
if user_features_enabled and command
|
||||
else None
|
||||
)
|
||||
event_identity = _event_identity(payload, source)
|
||||
if event_identity and not self._register_event(event_identity):
|
||||
return {
|
||||
@@ -51,7 +84,7 @@ class FeishuEventService:
|
||||
}
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=ActorValue.FEISHU,
|
||||
actor=principal.user_code if principal else ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FEISHU_EVENT_ACTIONS[source_value],
|
||||
target_type=source_value,
|
||||
@@ -60,18 +93,32 @@ class FeishuEventService:
|
||||
if event_identity
|
||||
else None
|
||||
),
|
||||
request_payload=_audit_event_metadata(payload),
|
||||
request_payload=_audit_event_metadata(
|
||||
payload,
|
||||
include_open_id=not user_features_enabled,
|
||||
include_identity_context=user_features_enabled,
|
||||
),
|
||||
response_payload={FeishuResponseKey.ACCEPTED: True},
|
||||
)
|
||||
)
|
||||
command = self.commands.extract_event_command(payload)
|
||||
if command is None:
|
||||
command = self.commands.extract_event_command(payload)
|
||||
if not command:
|
||||
return {FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False}
|
||||
result = self.commands.handle_text(
|
||||
command[FeishuCommandKey.TEXT],
|
||||
chat_id=command[FeishuCommandKey.CHAT_ID],
|
||||
actor=command[FeishuCommandKey.ACTOR],
|
||||
actor=(
|
||||
principal.user_code
|
||||
if principal
|
||||
else (
|
||||
ActorValue.FEISHU
|
||||
if user_features_enabled
|
||||
else command[FeishuCommandKey.ACTOR]
|
||||
)
|
||||
),
|
||||
auto_reply=auto_reply,
|
||||
principal=principal,
|
||||
)
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
@@ -79,6 +126,97 @@ class FeishuEventService:
|
||||
FeishuResponseKey.RESULT: result,
|
||||
}
|
||||
|
||||
def _handle_app_ticket_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: FeishuEventSource,
|
||||
) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
configured_app_id = str(settings.feishu_app_id or "").strip()
|
||||
if not configured_app_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="FEISHU_APP_ID is required for app ticket events",
|
||||
)
|
||||
|
||||
app_id, ticket = _app_ticket_fields(payload)
|
||||
if not app_id or not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Feishu app ticket event",
|
||||
)
|
||||
if app_id != configured_app_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Feishu app ticket app_id does not match configured application",
|
||||
)
|
||||
|
||||
event_identity = _event_identity(payload, source)
|
||||
if event_identity is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Feishu app ticket event identity is required",
|
||||
)
|
||||
if not self._register_event(event_identity):
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: False,
|
||||
FeishuResponseKey.DUPLICATE: True,
|
||||
}
|
||||
|
||||
FeishuAppTicketService(self.db).store_verified(app_id, ticket)
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FEISHU_EVENT_ACTIONS[source],
|
||||
target_type=source,
|
||||
target_id=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
|
||||
request_payload=_audit_event_metadata(payload),
|
||||
response_payload={FeishuResponseKey.ACCEPTED: True},
|
||||
)
|
||||
)
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
}
|
||||
|
||||
def _resolve_principal(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
command: dict[str, Any],
|
||||
) -> FeishuPrincipal | None:
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
sender = event.get(FeishuPayloadKey.SENDER) or {}
|
||||
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
|
||||
tenant_key = str(header.get(FeishuPayloadKey.TENANT_KEY) or "").strip()
|
||||
open_id = str(sender_id.get(FeishuPayloadKey.OPEN_ID) or "").strip()
|
||||
if not tenant_key or not open_id:
|
||||
return None
|
||||
principal = FeishuIdentityService(self.db).resolve_or_register(
|
||||
tenant_key=tenant_key,
|
||||
open_id=open_id,
|
||||
union_id=sender_id.get(FeishuPayloadKey.UNION_ID),
|
||||
user_id=sender_id.get(FeishuPayloadKey.USER_ID),
|
||||
)
|
||||
mentions_value = command.get(FeishuCommandKey.MENTIONS)
|
||||
mentions = (
|
||||
tuple(
|
||||
mention
|
||||
for mention in mentions_value
|
||||
if isinstance(mention, FeishuMention)
|
||||
)
|
||||
if isinstance(mentions_value, (list, tuple))
|
||||
else ()
|
||||
)
|
||||
return replace(
|
||||
principal,
|
||||
chat_id=command.get(FeishuCommandKey.CHAT_ID),
|
||||
chat_type=command.get(FeishuCommandKey.CHAT_TYPE),
|
||||
mentions=mentions,
|
||||
)
|
||||
|
||||
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
|
||||
receipt = FeishuEventReceipt(
|
||||
event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
|
||||
@@ -86,16 +224,21 @@ class FeishuEventService:
|
||||
event_id=event_identity.get(FeishuEventReceiptKey.EVENT_ID),
|
||||
message_id=event_identity.get(FeishuEventReceiptKey.MESSAGE_ID),
|
||||
)
|
||||
self.db.add(receipt)
|
||||
try:
|
||||
self.db.flush()
|
||||
with self.db.begin_nested():
|
||||
self.db.add(receipt)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _audit_event_metadata(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def _audit_event_metadata(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
include_open_id: bool = True,
|
||||
include_identity_context: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Keep webhook audit evidence without storing message content or tokens."""
|
||||
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
@@ -103,15 +246,23 @@ def _audit_event_metadata(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
sender = event.get(FeishuPayloadKey.SENDER) or {}
|
||||
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
|
||||
return {
|
||||
metadata = {
|
||||
"schema": payload.get("schema"),
|
||||
FeishuPayloadKey.EVENT_ID: header.get(FeishuPayloadKey.EVENT_ID),
|
||||
FeishuPayloadKey.EVENT_TYPE: header.get(FeishuPayloadKey.EVENT_TYPE),
|
||||
FeishuPayloadKey.EVENT_TYPE: _event_type(payload),
|
||||
FeishuPayloadKey.MESSAGE_ID: message.get(FeishuPayloadKey.MESSAGE_ID),
|
||||
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
|
||||
FeishuPayloadKey.MESSAGE_TYPE: message.get(FeishuPayloadKey.MESSAGE_TYPE),
|
||||
FeishuPayloadKey.OPEN_ID: sender_id.get(FeishuPayloadKey.OPEN_ID),
|
||||
}
|
||||
app_id, _ = _app_ticket_fields(payload)
|
||||
if app_id:
|
||||
metadata[FeishuPayloadKey.APP_ID] = app_id
|
||||
if include_identity_context:
|
||||
metadata[FeishuPayloadKey.TENANT_KEY] = header.get(FeishuPayloadKey.TENANT_KEY)
|
||||
metadata[FeishuCommandKey.CHAT_TYPE] = message.get(FeishuPayloadKey.CHAT_TYPE)
|
||||
if include_open_id:
|
||||
metadata[FeishuPayloadKey.OPEN_ID] = sender_id.get(FeishuPayloadKey.OPEN_ID)
|
||||
return metadata
|
||||
|
||||
|
||||
def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource:
|
||||
@@ -126,15 +277,21 @@ def _event_identity(
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
event_id = header.get(FeishuPayloadKey.EVENT_ID)
|
||||
event_id = (
|
||||
header.get(FeishuPayloadKey.EVENT_ID)
|
||||
or payload.get(FeishuPayloadKey.EVENT_ID)
|
||||
or payload.get(FeishuPayloadKey.UUID)
|
||||
or event.get(FeishuPayloadKey.UUID)
|
||||
)
|
||||
message_id = message.get(FeishuPayloadKey.MESSAGE_ID)
|
||||
stable_id = event_id or message_id
|
||||
if not stable_id:
|
||||
return None
|
||||
event_type = header.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
event_type = _event_type(payload)
|
||||
app_id, _ = _app_ticket_fields(payload)
|
||||
tenant_key = header.get(FeishuPayloadKey.TENANT_KEY) or app_id or "unknown-tenant"
|
||||
event_key = ":".join(
|
||||
str(part)
|
||||
for part in (source_value, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
str(part) for part in (tenant_key, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
)
|
||||
return {
|
||||
FeishuEventReceiptKey.EVENT_KEY: event_key,
|
||||
@@ -142,3 +299,41 @@ def _event_identity(
|
||||
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
|
||||
FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None,
|
||||
}
|
||||
|
||||
|
||||
def _event_type(payload: dict[str, Any]) -> str:
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
return str(
|
||||
header.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
or payload.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
or payload.get("type")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _app_ticket_fields(payload: dict[str, Any]) -> tuple[str, str]:
|
||||
header = payload.get(FeishuPayloadKey.HEADER)
|
||||
event = payload.get(FeishuPayloadKey.EVENT)
|
||||
data = payload.get(FeishuPayloadKey.DATA)
|
||||
candidates = [
|
||||
value
|
||||
for value in (event, data, header, payload)
|
||||
if isinstance(value, dict)
|
||||
]
|
||||
app_id = next(
|
||||
(
|
||||
str(candidate.get(FeishuPayloadKey.APP_ID) or "").strip()
|
||||
for candidate in candidates
|
||||
if candidate.get(FeishuPayloadKey.APP_ID)
|
||||
),
|
||||
"",
|
||||
)
|
||||
ticket = next(
|
||||
(
|
||||
str(candidate.get(APP_TICKET_PAYLOAD_KEY) or "").strip()
|
||||
for candidate in candidates
|
||||
if candidate.get(APP_TICKET_PAYLOAD_KEY)
|
||||
),
|
||||
"",
|
||||
)
|
||||
return app_id, ticket
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
from app.application.feishu.handlers.admin import (
|
||||
handle_admin_command,
|
||||
is_admin_command,
|
||||
)
|
||||
from app.application.feishu.handlers.finance import handle_finance_command
|
||||
from app.application.feishu.handlers.market import handle_market_command
|
||||
from app.application.feishu.handlers.rules import handle_rule_command
|
||||
from app.application.feishu.handlers.personalization import (
|
||||
handle_personalization_command,
|
||||
)
|
||||
from app.application.feishu.handlers.personal_data import (
|
||||
handle_personal_data_command,
|
||||
)
|
||||
from app.application.feishu.handlers.rules import (
|
||||
handle_rule_command,
|
||||
is_company_rule_command,
|
||||
)
|
||||
from app.application.feishu.handlers.subscriptions import handle_subscription_command
|
||||
|
||||
__all__ = [
|
||||
"handle_admin_command",
|
||||
"handle_finance_command",
|
||||
"handle_market_command",
|
||||
"handle_personalization_command",
|
||||
"handle_personal_data_command",
|
||||
"handle_rule_command",
|
||||
"handle_subscription_command",
|
||||
"is_admin_command",
|
||||
"is_company_rule_command",
|
||||
]
|
||||
|
||||
192
app/application/feishu/handlers/admin.py
Normal file
192
app/application/feishu/handlers/admin.py
Normal file
@@ -0,0 +1,192 @@
|
||||
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.constants import (
|
||||
FEISHU_USER_TARGET_TYPE,
|
||||
FeishuUserAuditAction,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
)
|
||||
from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal
|
||||
from app.modules.feishu_users.services import (
|
||||
FeishuIdentityService,
|
||||
FeishuUserManagementService,
|
||||
)
|
||||
|
||||
ADMIN_COMMAND_TITLE = "飞书用户管理"
|
||||
ADMIN_COMMAND_HELP = (
|
||||
"用户管理命令必须使用一个真实的飞书 @用户:\n"
|
||||
"设为管理员 @用户\n"
|
||||
"设为普通用户 @用户\n"
|
||||
"停用用户 @用户\n"
|
||||
"启用用户 @用户"
|
||||
)
|
||||
|
||||
_ADMIN_COMMANDS: dict[str, tuple[FeishuCommandName, dict[str, str], str]] = {
|
||||
"设为管理员": (
|
||||
FeishuCommandName.USER_SET_ADMIN,
|
||||
{"role": FeishuUserRole.ADMIN},
|
||||
"已设为管理员",
|
||||
),
|
||||
"设为普通用户": (
|
||||
FeishuCommandName.USER_SET_USER,
|
||||
{"role": FeishuUserRole.USER},
|
||||
"已设为普通用户",
|
||||
),
|
||||
"停用用户": (
|
||||
FeishuCommandName.USER_DISABLE,
|
||||
{"status": FeishuUserStatus.DISABLED},
|
||||
"已停用",
|
||||
),
|
||||
"启用用户": (
|
||||
FeishuCommandName.USER_ENABLE,
|
||||
{"status": FeishuUserStatus.ACTIVE},
|
||||
"已启用",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def is_admin_command(command_text: str) -> bool:
|
||||
return any(command_text.startswith(prefix) for prefix in _ADMIN_COMMANDS)
|
||||
|
||||
|
||||
def handle_admin_command(
|
||||
db: Session,
|
||||
feishu: FeishuService,
|
||||
*,
|
||||
raw_text: str,
|
||||
command_text: str,
|
||||
principal: FeishuPrincipal,
|
||||
auto_reply: bool,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Manage a user selected only from verified structured mention metadata."""
|
||||
|
||||
matched = next(
|
||||
(
|
||||
(prefix, definition)
|
||||
for prefix, definition in _ADMIN_COMMANDS.items()
|
||||
if command_text.startswith(prefix)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matched is None:
|
||||
return None
|
||||
command_prefix, definition = matched
|
||||
command, changes, success_text = definition
|
||||
target = _target_mention(raw_text, command_prefix, principal.mentions)
|
||||
if target is None or not target.open_id:
|
||||
_audit_denied(db, principal, "missing_or_ambiguous_structured_mention")
|
||||
return _result(
|
||||
feishu,
|
||||
command,
|
||||
ADMIN_COMMAND_HELP,
|
||||
principal,
|
||||
auto_reply,
|
||||
)
|
||||
target_tenant = target.tenant_key or principal.tenant_key
|
||||
if target_tenant != principal.tenant_key:
|
||||
_audit_denied(db, principal, "cross_tenant_target")
|
||||
return _result(
|
||||
feishu,
|
||||
command,
|
||||
"只能管理当前租户内通过飞书 @ 提及的用户。",
|
||||
principal,
|
||||
auto_reply,
|
||||
)
|
||||
|
||||
target_principal = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key=target_tenant,
|
||||
open_id=target.open_id,
|
||||
union_id=target.union_id,
|
||||
user_id=target.user_id,
|
||||
actor=principal.user_code,
|
||||
)
|
||||
try:
|
||||
updated = FeishuUserManagementService(db).update_user(
|
||||
target_principal.user_code,
|
||||
changes=changes,
|
||||
actor=principal.user_code,
|
||||
)
|
||||
display_name = target.name or updated.code
|
||||
content = f"{display_name} {success_text}。"
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 409:
|
||||
content = "操作已拒绝:不能停用或降级最后一个有效管理员。"
|
||||
else:
|
||||
content = "用户状态未修改,请确认目标用户后重试。"
|
||||
return _result(feishu, command, content, principal, auto_reply)
|
||||
|
||||
|
||||
def _target_mention(
|
||||
raw_text: str,
|
||||
command_text: str,
|
||||
mentions: tuple[FeishuMention, ...],
|
||||
) -> FeishuMention | None:
|
||||
command_index = raw_text.find(command_text)
|
||||
if command_index >= 0:
|
||||
command_tail = raw_text[command_index + len(command_text) :]
|
||||
candidates = [
|
||||
mention
|
||||
for mention in mentions
|
||||
if mention.key and mention.key in command_tail
|
||||
]
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
return None
|
||||
if len(mentions) == 1:
|
||||
return mentions[0]
|
||||
return None
|
||||
|
||||
|
||||
def _audit_denied(
|
||||
db: Session,
|
||||
principal: FeishuPrincipal,
|
||||
reason: str,
|
||||
) -> None:
|
||||
AuditService(db).record(
|
||||
AuditLogCreate(
|
||||
actor=principal.user_code,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FeishuUserAuditAction.UPDATE_DENIED,
|
||||
target_type=FEISHU_USER_TARGET_TYPE,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
response_payload={"result": "denied", "reason": reason},
|
||||
status="denied",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
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,
|
||||
ADMIN_COMMAND_TITLE,
|
||||
content,
|
||||
response,
|
||||
)
|
||||
@@ -9,6 +9,7 @@ from app.application.feishu.results import command_result
|
||||
from app.core.config import get_settings
|
||||
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.market.chart import render_market_chart
|
||||
from app.modules.market.service import MarketService
|
||||
|
||||
@@ -38,6 +39,7 @@ def handle_market_command(
|
||||
chat_id: str | None,
|
||||
actor: str,
|
||||
auto_reply: bool,
|
||||
principal: FeishuPrincipal | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Handle market analysis and watchlist commands."""
|
||||
|
||||
@@ -54,6 +56,31 @@ def handle_market_command(
|
||||
and not comparison
|
||||
):
|
||||
return None
|
||||
service = MarketService(db)
|
||||
owner_id = principal.owner_id if principal is not None else None
|
||||
if add:
|
||||
item = service.add_watchlist(actor, add.group(1), owner_id=owner_id)
|
||||
return _text_result(
|
||||
feishu,
|
||||
FeishuCommandName.WATCHLIST_ADD,
|
||||
"自选股",
|
||||
f"已加入自选:{item['symbol']}",
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
)
|
||||
if text == "查看自选":
|
||||
items = service.watchlist(actor, owner_id=owner_id)
|
||||
content = "自选股:" + ("、".join(item["symbol"] for item in items) or "暂无")
|
||||
return _text_result(
|
||||
feishu,
|
||||
FeishuCommandName.WATCHLIST_LIST,
|
||||
"自选股",
|
||||
content,
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
)
|
||||
if not get_settings().market_analysis_enabled:
|
||||
return _text_result(
|
||||
feishu,
|
||||
@@ -65,40 +92,6 @@ def handle_market_command(
|
||||
auto_reply,
|
||||
)
|
||||
|
||||
service = MarketService(db)
|
||||
if add:
|
||||
if get_settings().read_only_mode:
|
||||
return _text_result(
|
||||
feishu,
|
||||
FeishuCommandName.WATCHLIST_ADD,
|
||||
"自选股",
|
||||
"当前为只读模式,不能修改自选股。请由管理员启用操作后重试。",
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
)
|
||||
item = service.add_watchlist(actor, add.group(1))
|
||||
return _text_result(
|
||||
feishu,
|
||||
FeishuCommandName.WATCHLIST_ADD,
|
||||
"自选股",
|
||||
f"已加入自选:{item['symbol']}",
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
)
|
||||
if text == "查看自选":
|
||||
items = service.watchlist(actor)
|
||||
content = "自选股:" + ("、".join(item["symbol"] for item in items) or "暂无")
|
||||
return _text_result(
|
||||
feishu,
|
||||
FeishuCommandName.WATCHLIST_LIST,
|
||||
"自选股",
|
||||
content,
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
)
|
||||
if text == "最新公告":
|
||||
items = service.announcements(limit=10)["items"]
|
||||
content = (
|
||||
|
||||
267
app/application/feishu/handlers/personal_data.py
Normal file
267
app/application/feishu/handlers/personal_data.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.feishu.delivery import send_text_if_configured
|
||||
from app.application.feishu.personal_data import FeishuPersonalDataService
|
||||
from app.application.feishu.results import command_result
|
||||
from app.modules.ai_memory.constants import AIMemoryKind
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
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.business.models import MarketWatchlist
|
||||
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.models import AIConversation
|
||||
from app.modules.personalization.services import PreferenceService
|
||||
from app.modules.subscriptions.models import PushSubscription
|
||||
|
||||
PERSONAL_DATA_TITLE = "我的数据"
|
||||
_CONFIRM_PATTERN = re.compile(r"^确认忘记我\s+([A-Fa-f0-9]{8})$")
|
||||
_COMMAND_PREFIXES = ("我的数据", "忘记我", "确认忘记我")
|
||||
|
||||
|
||||
def handle_personal_data_command(
|
||||
db: Session,
|
||||
feishu: FeishuService,
|
||||
command_text: str,
|
||||
principal: FeishuPrincipal,
|
||||
auto_reply: bool,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Handle private data summaries and two-step account erasure."""
|
||||
|
||||
text = command_text.strip()
|
||||
if not text.startswith(_COMMAND_PREFIXES):
|
||||
return None
|
||||
principal.require_active()
|
||||
if principal.chat_type in {"group", "group_chat"}:
|
||||
return _result(
|
||||
feishu,
|
||||
FeishuCommandName.PERSONAL_DATA_SUMMARY,
|
||||
"为保护个人信息,请私聊机器人使用“我的数据”或“忘记我”。",
|
||||
principal,
|
||||
auto_reply,
|
||||
)
|
||||
if text == "我的数据":
|
||||
content = _summary(db, principal)
|
||||
_audit_summary(db, principal)
|
||||
return _result(
|
||||
feishu,
|
||||
FeishuCommandName.PERSONAL_DATA_SUMMARY,
|
||||
content,
|
||||
principal,
|
||||
auto_reply,
|
||||
)
|
||||
if text == "忘记我":
|
||||
confirmation = FeishuPersonalDataService(db).request_confirmation(principal)
|
||||
_audit_erasure_request(db, principal)
|
||||
content = (
|
||||
"该操作会永久删除你的个人规则、记忆、偏好、兴趣、"
|
||||
"会话、订阅和飞书身份映射。\n"
|
||||
f"确认码:{confirmation.confirmation_code}\n"
|
||||
f"有效期至:{confirmation.expires_at.strftime('%Y-%m-%d %H:%M:%S')} UTC\n"
|
||||
f"确认命令:确认忘记我 {confirmation.confirmation_code}"
|
||||
)
|
||||
return _result(
|
||||
feishu,
|
||||
FeishuCommandName.PERSONAL_DATA_ERASURE_REQUEST,
|
||||
content,
|
||||
principal,
|
||||
auto_reply,
|
||||
)
|
||||
|
||||
match = _CONFIRM_PATTERN.fullmatch(text)
|
||||
if match is None:
|
||||
return _result(
|
||||
feishu,
|
||||
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM,
|
||||
"格式不正确。请先发送“忘记我”获取一次性确认码。",
|
||||
principal,
|
||||
auto_reply,
|
||||
)
|
||||
try:
|
||||
erased = FeishuPersonalDataService(db).confirm(principal, match.group(1))
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
content = (
|
||||
"不能删除最后一个有效管理员,请先设置另一名管理员。"
|
||||
if exc.status_code == 409
|
||||
else "确认码无效或已过期,请重新发送“忘记我”。"
|
||||
)
|
||||
return _result(
|
||||
feishu,
|
||||
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM,
|
||||
content,
|
||||
principal,
|
||||
auto_reply,
|
||||
)
|
||||
return _result(
|
||||
feishu,
|
||||
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM,
|
||||
"你的个人数据和飞书身份映射已删除。再次联系时会创建新的普通用户身份。",
|
||||
principal,
|
||||
auto_reply,
|
||||
audit_actor=erased.anonymous_id,
|
||||
)
|
||||
|
||||
|
||||
def _summary(db: Session, principal: FeishuPrincipal) -> str:
|
||||
owner_id = principal.owner_id
|
||||
preferences = PreferenceService(db).list_preferences(owner_id)
|
||||
interest_categories = {
|
||||
PreferenceCategory.TOPIC,
|
||||
PreferenceCategory.INTEREST,
|
||||
}
|
||||
profile_preferences = [
|
||||
item for item in preferences if item["category"] not in interest_categories
|
||||
]
|
||||
interest_preferences = [
|
||||
item for item in preferences if item["category"] in interest_categories
|
||||
]
|
||||
watchlist = list(
|
||||
db.execute(
|
||||
select(MarketWatchlist.symbol)
|
||||
.where(
|
||||
MarketWatchlist.owner_id == owner_id,
|
||||
MarketWatchlist.enabled.is_(True),
|
||||
)
|
||||
.order_by(MarketWatchlist.symbol.asc())
|
||||
).scalars()
|
||||
)
|
||||
subscription_statuses = Counter(
|
||||
db.execute(
|
||||
select(PushSubscription.status).where(
|
||||
PushSubscription.owner_id == owner_id
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
personal_rule_count = _memory_count(
|
||||
db,
|
||||
owner_id,
|
||||
AIMemoryKind.PERSONAL_RULE,
|
||||
)
|
||||
memory_count = _memory_count(db, owner_id, AIMemoryKind.MEMORY)
|
||||
conversation_count = int(
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(AIConversation)
|
||||
.where(AIConversation.owner_id == owner_id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
preference_text = "、".join(
|
||||
f"{item['category']}={item['value']}" for item in profile_preferences[:10]
|
||||
)
|
||||
interest_values = [
|
||||
*(str(item["value"]) for item in interest_preferences),
|
||||
*watchlist,
|
||||
]
|
||||
interest_text = "、".join(interest_values[:10])
|
||||
subscriptions_text = "、".join(
|
||||
f"{status} {count}" for status, count in sorted(subscription_statuses.items())
|
||||
)
|
||||
return "\n".join(
|
||||
[
|
||||
"我的个人数据摘要:",
|
||||
f"个人规则:{personal_rule_count} 条",
|
||||
f"偏好:{preference_text or '暂无'}",
|
||||
f"兴趣与自选:{interest_text or '暂无'}",
|
||||
f"个人记忆:{memory_count} 条",
|
||||
f"会话:{conversation_count} 个",
|
||||
f"订阅:{subscriptions_text or '暂无'}",
|
||||
"可发送“我的偏好”“查看规则”“我的订阅”查看明细。",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _memory_count(
|
||||
db: Session,
|
||||
owner_id: int,
|
||||
kind: str,
|
||||
) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(AIMemoryEntry)
|
||||
.where(
|
||||
AIMemoryEntry.owner_id == owner_id,
|
||||
AIMemoryEntry.kind == kind,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _audit_summary(db: Session, principal: FeishuPrincipal) -> None:
|
||||
_audit(
|
||||
db,
|
||||
principal,
|
||||
action="personalization.data.summary",
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
)
|
||||
|
||||
|
||||
def _audit_erasure_request(db: Session, principal: FeishuPrincipal) -> None:
|
||||
_audit(
|
||||
db,
|
||||
principal,
|
||||
action="personalization.erasure.request",
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
)
|
||||
|
||||
|
||||
def _audit(
|
||||
db: Session,
|
||||
principal: FeishuPrincipal,
|
||||
*,
|
||||
action: str,
|
||||
risk_level: str,
|
||||
) -> None:
|
||||
AuditService(db).record(
|
||||
AuditLogCreate(
|
||||
actor=principal.user_code,
|
||||
source=AuditSource.FEISHU,
|
||||
action=action,
|
||||
target_type="feishu-user",
|
||||
target_id=principal.user_code,
|
||||
risk_level=risk_level,
|
||||
response_payload={"result": "success"},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _result(
|
||||
feishu: FeishuService,
|
||||
command: FeishuCommandName,
|
||||
content: str,
|
||||
principal: FeishuPrincipal,
|
||||
auto_reply: bool,
|
||||
*,
|
||||
audit_actor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
response = (
|
||||
send_text_if_configured(
|
||||
feishu,
|
||||
principal.chat_id,
|
||||
content,
|
||||
audit_actor or principal.user_code,
|
||||
record_audit=audit_actor is None,
|
||||
)
|
||||
if auto_reply
|
||||
else None
|
||||
)
|
||||
return command_result(
|
||||
command,
|
||||
FeishuReplyType.TEXT,
|
||||
PERSONAL_DATA_TITLE,
|
||||
content,
|
||||
response,
|
||||
)
|
||||
300
app/application/feishu/handlers/personalization.py
Normal file
300
app/application/feishu/handlers/personalization.py
Normal 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,
|
||||
)
|
||||
@@ -11,32 +11,60 @@ from app.modules.ai_memory.constants import AIMemoryStatus
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.feishu_users.constants import FeishuCapability
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
|
||||
RULE_TITLE = "AI 学习规则"
|
||||
RULE_CREATE_PATTERN = re.compile(r"^学习规则(?:\s+(\d{1,3}))?\s*[::]\s*(.*)$")
|
||||
MARKET_RULE_CREATE_PATTERN = re.compile(
|
||||
r"^学习市场规则(?:\s+(\d{1,3}))?\s*[::]\s*(.*)$"
|
||||
)
|
||||
RULE_UPDATE_PATTERN = re.compile(
|
||||
r"^修改规则\s+(MEM-[A-Za-z0-9-]+)(?:\s+(\d{1,3}))?\s*[::]\s*(.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
RULE_DISABLE_PATTERN = re.compile(r"^停用规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
|
||||
RULE_ENABLE_PATTERN = re.compile(r"^启用规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
|
||||
RULE_DELETE_PATTERN = re.compile(r"^删除规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
|
||||
RULE_LIST_COMMANDS = {"查看规则", "规则列表", "查看市场规则"}
|
||||
RULE_COMMAND_PREFIXES = (
|
||||
"学习公司市场规则",
|
||||
"学习公司规则",
|
||||
"查看公司市场规则",
|
||||
"查看公司规则",
|
||||
"修改公司规则",
|
||||
"启用公司规则",
|
||||
"停用公司规则",
|
||||
"删除公司规则",
|
||||
"学习市场规则",
|
||||
"学习规则",
|
||||
"查看市场规则",
|
||||
"查看规则",
|
||||
"规则列表",
|
||||
"修改规则",
|
||||
"停用规则",
|
||||
"启用规则",
|
||||
"删除规则",
|
||||
)
|
||||
RULE_COMMAND_HELP = (
|
||||
"规则指令格式:\n"
|
||||
"学习规则:<规则内容>\n"
|
||||
"学习规则 80:<规则内容>\n"
|
||||
"学习市场规则 80:<仅用于市场分析的规则内容>\n"
|
||||
"查看规则\n"
|
||||
"停用规则 <规则编号>\n"
|
||||
"启用规则 <规则编号>"
|
||||
"修改规则 <编号> 80:<新内容>\n"
|
||||
"启用规则 <编号>\n"
|
||||
"停用规则 <编号>\n"
|
||||
"删除规则 <编号>"
|
||||
)
|
||||
|
||||
_COMPANY_REPLACEMENTS = (
|
||||
("学习公司市场规则", "学习市场规则"),
|
||||
("查看公司市场规则", "查看市场规则"),
|
||||
("学习公司规则", "学习规则"),
|
||||
("查看公司规则", "查看规则"),
|
||||
("修改公司规则", "修改规则"),
|
||||
("启用公司规则", "启用规则"),
|
||||
("停用公司规则", "停用规则"),
|
||||
("删除公司规则", "删除规则"),
|
||||
)
|
||||
|
||||
|
||||
@@ -47,29 +75,40 @@ def handle_rule_command(
|
||||
chat_id: str | None,
|
||||
actor: str,
|
||||
auto_reply: bool,
|
||||
principal: FeishuPrincipal | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Handle persistent AI rule commands."""
|
||||
"""Handle company or owner-scoped persistent AI rule commands."""
|
||||
|
||||
if not command_text.startswith(RULE_COMMAND_PREFIXES):
|
||||
return None
|
||||
command = _command_name(command_text)
|
||||
if command in {
|
||||
FeishuCommandName.RULE_CREATE,
|
||||
FeishuCommandName.RULE_DISABLE,
|
||||
FeishuCommandName.RULE_ENABLE,
|
||||
} and get_settings().read_only_mode:
|
||||
return _result(
|
||||
feishu,
|
||||
command,
|
||||
"当前为只读模式,不能新增或修改学习规则。请由管理员启用操作后重试。",
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
)
|
||||
normalized, company_rule = _normalize_company_command(command_text)
|
||||
owner_id: int | None = None
|
||||
if get_settings().feishu_user_features_enabled:
|
||||
if principal is None:
|
||||
return _result(
|
||||
feishu,
|
||||
FeishuCommandName.PERMISSION_DENIED,
|
||||
"个人规则只能由已验证的飞书账号管理。",
|
||||
chat_id,
|
||||
actor,
|
||||
auto_reply,
|
||||
)
|
||||
if company_rule:
|
||||
principal.require_capability(FeishuCapability.COMPANY_RULES)
|
||||
else:
|
||||
owner_id = principal.owner_id
|
||||
|
||||
command = _command_name(normalized)
|
||||
content = RULE_COMMAND_HELP
|
||||
try:
|
||||
command, content = _execute(db, command_text, command, actor)
|
||||
command, content = _execute(
|
||||
db,
|
||||
normalized,
|
||||
command,
|
||||
actor,
|
||||
owner_id=owner_id,
|
||||
company_rule=company_rule or owner_id is None,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
detail = str(exc.detail)
|
||||
if "secret-like" in detail:
|
||||
@@ -83,21 +122,65 @@ def handle_rule_command(
|
||||
return _result(feishu, command, content, chat_id, actor, auto_reply)
|
||||
|
||||
|
||||
def is_company_rule_command(command_text: str) -> bool:
|
||||
return any(command_text.startswith(prefix) for prefix, _ in _COMPANY_REPLACEMENTS)
|
||||
|
||||
|
||||
def _normalize_company_command(command_text: str) -> tuple[str, bool]:
|
||||
for prefix, replacement in _COMPANY_REPLACEMENTS:
|
||||
if command_text.startswith(prefix):
|
||||
return replacement + command_text[len(prefix) :], True
|
||||
return command_text, False
|
||||
|
||||
|
||||
def _execute(
|
||||
db: Session,
|
||||
command_text: str,
|
||||
command: FeishuCommandName,
|
||||
actor: str,
|
||||
*,
|
||||
owner_id: int | None,
|
||||
company_rule: bool,
|
||||
) -> tuple[FeishuCommandName, str]:
|
||||
market_create_match = MARKET_RULE_CREATE_PATTERN.fullmatch(command_text)
|
||||
create_match = market_create_match or RULE_CREATE_PATTERN.fullmatch(command_text)
|
||||
update_match = RULE_UPDATE_PATTERN.fullmatch(command_text)
|
||||
disable_match = RULE_DISABLE_PATTERN.fullmatch(command_text)
|
||||
enable_match = RULE_ENABLE_PATTERN.fullmatch(command_text)
|
||||
delete_match = RULE_DELETE_PATTERN.fullmatch(command_text)
|
||||
memory = AIMemoryService(db)
|
||||
if create_match:
|
||||
return command, _create_rule(memory, create_match, market_create_match is not None, actor)
|
||||
return (
|
||||
command,
|
||||
_create_rule(
|
||||
memory,
|
||||
create_match,
|
||||
market_create_match is not None,
|
||||
actor,
|
||||
owner_id,
|
||||
company_rule,
|
||||
),
|
||||
)
|
||||
if command_text in RULE_LIST_COMMANDS:
|
||||
return FeishuCommandName.RULE_LIST, _list_rules(memory, command_text)
|
||||
return (
|
||||
FeishuCommandName.RULE_LIST,
|
||||
_list_rules(memory, command_text, owner_id, company_rule),
|
||||
)
|
||||
if update_match:
|
||||
priority = int(update_match.group(2) or 50)
|
||||
content = update_match.group(3).strip()
|
||||
if not content:
|
||||
return FeishuCommandName.RULE_UPDATE, "规则内容不能为空。"
|
||||
rule = memory.update_rule(
|
||||
code=update_match.group(1),
|
||||
content=content,
|
||||
priority=priority,
|
||||
tags=None,
|
||||
enabled=None,
|
||||
actor=actor,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
return FeishuCommandName.RULE_UPDATE, _rule_state(rule, "已修改")
|
||||
if disable_match or enable_match:
|
||||
enabled = enable_match is not None
|
||||
match = enable_match or disable_match
|
||||
@@ -108,16 +191,16 @@ def _execute(
|
||||
tags=None,
|
||||
enabled=enabled,
|
||||
actor=actor,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
state = "已启用" if enabled else "已停用"
|
||||
return (
|
||||
FeishuCommandName.RULE_ENABLE if enabled else FeishuCommandName.RULE_DISABLE,
|
||||
f"规则{state}。\n"
|
||||
f"编号:{rule['code']}\n"
|
||||
f"优先级:{rule['importance']}\n"
|
||||
f"范围:{rule['scope']} / {rule['subject']}\n"
|
||||
f"状态:{state}",
|
||||
_rule_state(rule, state),
|
||||
)
|
||||
if delete_match:
|
||||
memory.delete_rule(delete_match.group(1), actor=actor, owner_id=owner_id)
|
||||
return FeishuCommandName.RULE_DELETE, "规则已删除。"
|
||||
return command, RULE_COMMAND_HELP
|
||||
|
||||
|
||||
@@ -126,6 +209,8 @@ def _create_rule(
|
||||
match: re.Match[str],
|
||||
market_rule: bool,
|
||||
actor: str,
|
||||
owner_id: int | None,
|
||||
company_rule: bool,
|
||||
) -> str:
|
||||
priority = int(match.group(1) or 50)
|
||||
content = match.group(2).strip()
|
||||
@@ -136,29 +221,39 @@ def _create_rule(
|
||||
rule = memory.create_rule(
|
||||
content=content,
|
||||
scope="market" if market_rule else "global",
|
||||
subject="market" if market_rule else "company",
|
||||
subject=(
|
||||
"market"
|
||||
if market_rule
|
||||
else ("company" if company_rule else "personal")
|
||||
),
|
||||
priority=priority,
|
||||
tags=["feishu", *(["market"] if market_rule else [])],
|
||||
tags=[
|
||||
"feishu",
|
||||
"company" if company_rule else "personal",
|
||||
*(["market"] if market_rule else []),
|
||||
],
|
||||
actor=actor,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
return (
|
||||
"规则已学习。\n"
|
||||
f"编号:{rule['code']}\n"
|
||||
f"优先级:{rule['importance']}\n"
|
||||
f"范围:{rule['scope']} / {rule['subject']}\n"
|
||||
"状态:已启用"
|
||||
)
|
||||
return _rule_state(rule, "已学习")
|
||||
|
||||
|
||||
def _list_rules(memory: AIMemoryService, command_text: str) -> str:
|
||||
def _list_rules(
|
||||
memory: AIMemoryService,
|
||||
command_text: str,
|
||||
owner_id: int | None,
|
||||
company_rule: bool,
|
||||
) -> str:
|
||||
rules = memory.list_rules(
|
||||
scope="market" if command_text == "查看市场规则" else None,
|
||||
status_filter=AIMemoryStatus.ACTIVE,
|
||||
limit=20,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if not rules:
|
||||
return "当前没有已启用的学习规则。"
|
||||
lines = ["当前已启用的学习规则:"]
|
||||
target = "公司" if company_rule else "个人"
|
||||
return f"当前没有已启用的{target}规则。"
|
||||
lines = ["当前已启用的公司规则:" if company_rule else "当前已启用的个人规则:"]
|
||||
for rule in rules:
|
||||
rule_text = str(rule["content"])
|
||||
if len(rule_text) > 80:
|
||||
@@ -170,11 +265,25 @@ def _list_rules(memory: AIMemoryService, command_text: str) -> str:
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _rule_state(rule: dict[str, Any], state: str) -> str:
|
||||
return (
|
||||
f"规则{state}。\n"
|
||||
f"编号:{rule['code']}\n"
|
||||
f"优先级:{rule['importance']}\n"
|
||||
f"范围:{rule['scope']} / {rule['subject']}\n"
|
||||
f"状态:{state}"
|
||||
)
|
||||
|
||||
|
||||
def _command_name(command_text: str) -> FeishuCommandName:
|
||||
if command_text.startswith("停用规则"):
|
||||
return FeishuCommandName.RULE_DISABLE
|
||||
if command_text.startswith("启用规则"):
|
||||
return FeishuCommandName.RULE_ENABLE
|
||||
if command_text.startswith("修改规则"):
|
||||
return FeishuCommandName.RULE_UPDATE
|
||||
if command_text.startswith("删除规则"):
|
||||
return FeishuCommandName.RULE_DELETE
|
||||
if command_text.startswith(("查看市场规则", "查看规则", "规则列表")):
|
||||
return FeishuCommandName.RULE_LIST
|
||||
return FeishuCommandName.RULE_CREATE
|
||||
|
||||
321
app/application/feishu/handlers/subscriptions.py
Normal file
321
app/application/feishu/handlers/subscriptions.py
Normal file
@@ -0,0 +1,321 @@
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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.feishu.constants import FeishuCommandName, FeishuReplyType
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
from app.modules.subscriptions.constants import (
|
||||
DAILY_DELIVERY_LIMIT_REACHED,
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services import (
|
||||
ScheduleParseError,
|
||||
SubscriptionManagementService,
|
||||
parse_schedule,
|
||||
)
|
||||
|
||||
SUBSCRIPTION_TITLE = "订阅管理"
|
||||
SUBSCRIPTION_HELP = (
|
||||
"订阅指令格式:\n"
|
||||
"订阅 每天 09:00:提示词\n"
|
||||
"订阅 工作日 18:00:提示词\n"
|
||||
"订阅 每周一 09:00:提示词\n"
|
||||
"订阅 每月1号 09:00:提示词\n"
|
||||
"订阅 每隔30分钟:提示词\n"
|
||||
"我的订阅\n"
|
||||
"暂停订阅 <订阅编号>\n"
|
||||
"恢复订阅 <订阅编号>\n"
|
||||
"退订 <订阅编号>\n"
|
||||
"设置时区 Asia/Shanghai\n"
|
||||
"设置安静时段 22:00-07:00\n"
|
||||
"关闭安静时段"
|
||||
)
|
||||
_LIST_COMMANDS = {"我的订阅", "查看订阅"}
|
||||
_PAUSE_PATTERN = re.compile(
|
||||
r"^(?:暂停订阅|停用订阅)\s+(SUB-[A-Za-z0-9-]+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RESUME_PATTERN = re.compile(
|
||||
r"^(?:恢复订阅|启用订阅)\s+(SUB-[A-Za-z0-9-]+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CANCEL_PATTERN = re.compile(
|
||||
r"^(?:退订|取消订阅)\s+(SUB-[A-Za-z0-9-]+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TIMEZONE_PATTERN = re.compile(r"^设置时区\s+(\S+)$")
|
||||
_QUIET_PATTERN = re.compile(
|
||||
r"^设置安静时段\s+(\d{1,2}(?:[::]\d{1,2}))"
|
||||
r"\s*(?:-|~|至|到)\s*(\d{1,2}(?:[::]\d{1,2}))$"
|
||||
)
|
||||
_CLOSE_QUIET_COMMAND = "关闭安静时段"
|
||||
_COMMAND_PREFIXES = (
|
||||
"订阅",
|
||||
"我的订阅",
|
||||
"查看订阅",
|
||||
"暂停订阅",
|
||||
"停用订阅",
|
||||
"恢复订阅",
|
||||
"启用订阅",
|
||||
"退订",
|
||||
"取消订阅",
|
||||
"设置时区",
|
||||
"设置安静时段",
|
||||
"关闭安静时段",
|
||||
)
|
||||
|
||||
|
||||
def handle_subscription_command(
|
||||
db: Session,
|
||||
feishu: FeishuService,
|
||||
command_text: str,
|
||||
principal: FeishuPrincipal,
|
||||
auto_reply: bool,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Handle self-service subscriptions for a verified Feishu principal."""
|
||||
|
||||
text = command_text.strip()
|
||||
if not text.startswith(_COMMAND_PREFIXES):
|
||||
return None
|
||||
service = SubscriptionManagementService(db)
|
||||
command = _command_name(text)
|
||||
try:
|
||||
content = _execute(service, text, principal)
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
content = _error_content(exc)
|
||||
return _result(feishu, command, content, principal, auto_reply)
|
||||
|
||||
|
||||
def _execute(
|
||||
service: SubscriptionManagementService,
|
||||
command_text: str,
|
||||
principal: FeishuPrincipal,
|
||||
) -> str:
|
||||
if command_text.startswith("订阅"):
|
||||
parts = _create_parts(command_text, principal.timezone)
|
||||
if parts is None:
|
||||
return f"无法识别订阅时间或提示词。\n\n{SUBSCRIPTION_HELP}"
|
||||
schedule_expression, prompt = parts
|
||||
if principal.chat_type in {"group", "group_chat"}:
|
||||
subscription, schedule = service.create_group(
|
||||
principal,
|
||||
schedule_expression,
|
||||
prompt,
|
||||
)
|
||||
else:
|
||||
subscription, schedule = service.create_private(
|
||||
principal,
|
||||
schedule_expression,
|
||||
prompt,
|
||||
)
|
||||
return (
|
||||
"订阅已启用。\n"
|
||||
f"编号:{subscription.code}\n"
|
||||
f"计划:{schedule.display}\n"
|
||||
f"时区:{schedule.timezone}\n"
|
||||
f"下次执行:{_format_next(schedule.next_run_at, schedule.timezone)}\n"
|
||||
f"暂停命令:暂停订阅 {subscription.code}"
|
||||
)
|
||||
if command_text in _LIST_COMMANDS:
|
||||
return _list_content(
|
||||
service.list_for_owner(principal),
|
||||
service.latest_deliveries_for_owner(principal),
|
||||
)
|
||||
|
||||
pause_match = _PAUSE_PATTERN.fullmatch(command_text)
|
||||
if pause_match:
|
||||
record = service.pause(principal, pause_match.group(1))
|
||||
return f"订阅已暂停。\n编号:{record.code}\n恢复命令:恢复订阅 {record.code}"
|
||||
|
||||
resume_match = _RESUME_PATTERN.fullmatch(command_text)
|
||||
if resume_match:
|
||||
record = service.resume(principal, resume_match.group(1))
|
||||
return (
|
||||
"订阅已恢复。\n"
|
||||
f"编号:{record.code}\n"
|
||||
f"下次执行:{_format_next(record.next_run_at, record.timezone)}\n"
|
||||
f"暂停命令:暂停订阅 {record.code}"
|
||||
)
|
||||
|
||||
cancel_match = _CANCEL_PATTERN.fullmatch(command_text)
|
||||
if cancel_match:
|
||||
record = service.cancel(principal, cancel_match.group(1))
|
||||
return f"已退订。\n编号:{record.code}"
|
||||
|
||||
timezone_match = _TIMEZONE_PATTERN.fullmatch(command_text)
|
||||
if timezone_match:
|
||||
owner = service.set_timezone(principal, timezone_match.group(1))
|
||||
return f"时区已设置为 {owner.timezone}。"
|
||||
|
||||
quiet_match = _QUIET_PATTERN.fullmatch(command_text)
|
||||
if quiet_match:
|
||||
owner = service.set_quiet_hours(
|
||||
principal,
|
||||
quiet_match.group(1),
|
||||
quiet_match.group(2),
|
||||
)
|
||||
return (
|
||||
"安静时段已设置。\n"
|
||||
f"{owner.quiet_hours_start.strftime('%H:%M')}"
|
||||
f"-{owner.quiet_hours_end.strftime('%H:%M')}"
|
||||
)
|
||||
|
||||
if command_text == _CLOSE_QUIET_COMMAND:
|
||||
service.clear_quiet_hours(principal)
|
||||
return "安静时段已关闭。"
|
||||
return SUBSCRIPTION_HELP
|
||||
|
||||
|
||||
def _create_parts(command_text: str, timezone_name: str) -> tuple[str, str] | None:
|
||||
payload = command_text.removeprefix("订阅").strip()
|
||||
separator_indexes = [
|
||||
index for index, character in enumerate(payload) if character in {":", ":"}
|
||||
]
|
||||
for index in reversed(separator_indexes):
|
||||
schedule_expression = payload[:index].strip()
|
||||
prompt = payload[index + 1 :].strip()
|
||||
if not schedule_expression or not prompt:
|
||||
continue
|
||||
try:
|
||||
parse_schedule(schedule_expression, timezone_name)
|
||||
except ScheduleParseError:
|
||||
continue
|
||||
return schedule_expression, prompt
|
||||
return None
|
||||
|
||||
|
||||
def _list_content(
|
||||
records: list[PushSubscription],
|
||||
latest_deliveries: dict[int, PushDelivery],
|
||||
) -> str:
|
||||
if not records:
|
||||
return "当前没有订阅。\n\n" + SUBSCRIPTION_HELP.splitlines()[0]
|
||||
lines = ["我的订阅:"]
|
||||
for record in records:
|
||||
prompt = record.prompt if len(record.prompt) <= 40 else f"{record.prompt[:40]}…"
|
||||
target = (
|
||||
"私聊"
|
||||
if record.target_type == SubscriptionTargetType.USER
|
||||
else "当前群"
|
||||
)
|
||||
lines.append(
|
||||
f"{record.code}|{_status_name(record.status)}|{target}\n"
|
||||
f"{_schedule_name(record)}|下次 {_format_next(record.next_run_at, record.timezone)}\n"
|
||||
f"{prompt}{_delivery_note(latest_deliveries.get(record.id))}"
|
||||
)
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _schedule_name(record: PushSubscription) -> str:
|
||||
config = record.schedule_config
|
||||
if record.schedule_type == SubscriptionScheduleType.ONCE:
|
||||
return "单次"
|
||||
if record.schedule_type == SubscriptionScheduleType.INTERVAL:
|
||||
return f"每隔 {config['minutes']} 分钟"
|
||||
clock = f"{int(config['hour']):02d}:{int(config['minute']):02d}"
|
||||
if record.schedule_type == SubscriptionScheduleType.DAILY:
|
||||
return f"每天 {clock}"
|
||||
if record.schedule_type == SubscriptionScheduleType.WEEKDAY:
|
||||
return f"工作日 {clock}"
|
||||
if record.schedule_type == SubscriptionScheduleType.WEEKLY:
|
||||
names = "一二三四五六日"
|
||||
return f"每周{names[int(config['weekday'])]} {clock}"
|
||||
return f"每月 {config['day']} 号 {clock}"
|
||||
|
||||
|
||||
def _status_name(status_value: str) -> str:
|
||||
return {
|
||||
PushSubscriptionStatus.ACTIVE: "已启用",
|
||||
PushSubscriptionStatus.PAUSED: "已暂停",
|
||||
PushSubscriptionStatus.CANCELLED: "已退订",
|
||||
PushSubscriptionStatus.COMPLETED: "已完成",
|
||||
}.get(status_value, status_value)
|
||||
|
||||
|
||||
def _delivery_note(delivery: PushDelivery | None) -> str:
|
||||
if delivery is None:
|
||||
return ""
|
||||
if (
|
||||
delivery.status == PushDeliveryStatus.SKIPPED
|
||||
and delivery.last_error == DAILY_DELIVERY_LIMIT_REACHED
|
||||
):
|
||||
return "\n最近投递:因每日最多 96 条限制已跳过"
|
||||
if delivery.status == PushDeliveryStatus.RETRY:
|
||||
return "\n最近投递:发送失败,正在按 1/5/15 分钟重试"
|
||||
if delivery.status == PushDeliveryStatus.FAILED:
|
||||
return "\n最近投递:重试后仍失败,请联系管理员"
|
||||
if delivery.status == PushDeliveryStatus.SKIPPED:
|
||||
return "\n最近投递:因账号、订阅状态或安静时段限制已跳过"
|
||||
return ""
|
||||
|
||||
|
||||
def _format_next(value: datetime | None, timezone_name: str) -> str:
|
||||
if value is None:
|
||||
return "无"
|
||||
aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||
return aware.astimezone(ZoneInfo(timezone_name)).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _command_name(command_text: str) -> FeishuCommandName:
|
||||
if command_text in _LIST_COMMANDS:
|
||||
return FeishuCommandName.SUBSCRIPTION_LIST
|
||||
if _PAUSE_PATTERN.fullmatch(command_text):
|
||||
return FeishuCommandName.SUBSCRIPTION_PAUSE
|
||||
if _RESUME_PATTERN.fullmatch(command_text):
|
||||
return FeishuCommandName.SUBSCRIPTION_RESUME
|
||||
if _CANCEL_PATTERN.fullmatch(command_text):
|
||||
return FeishuCommandName.SUBSCRIPTION_CANCEL
|
||||
if _TIMEZONE_PATTERN.fullmatch(command_text):
|
||||
return FeishuCommandName.SUBSCRIPTION_TIMEZONE
|
||||
if _QUIET_PATTERN.fullmatch(command_text) or command_text == _CLOSE_QUIET_COMMAND:
|
||||
return FeishuCommandName.SUBSCRIPTION_QUIET_HOURS
|
||||
return FeishuCommandName.SUBSCRIPTION_CREATE
|
||||
|
||||
|
||||
def _error_content(exc: HTTPException) -> str:
|
||||
detail = str(exc.detail)
|
||||
if exc.status_code == 404:
|
||||
return "没有找到该订阅,请先发送“我的订阅”确认编号。"
|
||||
if exc.status_code == 409 and "50" in detail:
|
||||
return "已达到最多 50 个启用订阅,请先暂停或退订现有订阅。"
|
||||
if exc.status_code == 403:
|
||||
return "当前飞书账号无权执行该订阅操作。"
|
||||
return f"订阅指令未执行:{detail}\n\n{SUBSCRIPTION_HELP}"
|
||||
|
||||
|
||||
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,
|
||||
SUBSCRIPTION_TITLE,
|
||||
content,
|
||||
response,
|
||||
)
|
||||
324
app/application/feishu/personal_data.py
Normal file
324
app/application/feishu/personal_data.py
Normal file
@@ -0,0 +1,324 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.feishu_users.constants import (
|
||||
FEISHU_USER_NOT_FOUND,
|
||||
LAST_ACTIVE_ADMIN_ERROR,
|
||||
FeishuCapability,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
parse_admin_identities,
|
||||
)
|
||||
from app.modules.feishu_users.bootstrap import admin_bootstrap_identity_hash
|
||||
from app.modules.feishu_users.models import (
|
||||
FeishuAdminBootstrapTombstone,
|
||||
FeishuUser,
|
||||
)
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
from app.modules.personalization.schemas import ErasureConfirmation, ErasureResult
|
||||
from app.modules.personalization.services.erasure import (
|
||||
ErasureHook,
|
||||
PersonalDataErasureService,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
|
||||
PERSONAL_DATA_ERASURE_ACTION = "feishu.user.personal_data_erased"
|
||||
|
||||
|
||||
class FeishuPersonalDataService:
|
||||
"""Coordinate Feishu identity erasure across personal-data domains."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
extra_hooks: Sequence[ErasureHook] = (),
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.extra_hooks = tuple(extra_hooks)
|
||||
self.erasure = PersonalDataErasureService(db)
|
||||
|
||||
def request_confirmation(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
) -> ErasureConfirmation:
|
||||
"""Issue a short-lived confirmation code for the authenticated user."""
|
||||
|
||||
principal.require_capability(FeishuCapability.PERSONAL_DATA)
|
||||
user = self._find_principal_user(principal)
|
||||
if user.status != FeishuUserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Feishu user is disabled",
|
||||
)
|
||||
return self.erasure.request_confirmation(user.id)
|
||||
|
||||
def confirm(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
confirmation_code: str,
|
||||
) -> ErasureResult:
|
||||
"""Confirm and erase the authenticated user's personal data."""
|
||||
|
||||
principal.require_capability(FeishuCapability.PERSONAL_DATA)
|
||||
user = self._lock_principal_user(principal)
|
||||
if user.status != FeishuUserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Feishu user is disabled",
|
||||
)
|
||||
return self._confirm_and_erase(
|
||||
user,
|
||||
confirmation_code,
|
||||
audit_source=AuditSource.FEISHU,
|
||||
)
|
||||
|
||||
def erase_by_user_code(self, code: str, *, actor: str) -> ErasureResult:
|
||||
"""Erase a user selected by an authenticated internal service."""
|
||||
|
||||
if not actor.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Authenticated service actor is required",
|
||||
)
|
||||
user = self._lock_user(code=code)
|
||||
confirmation = self.erasure.request_confirmation(user.id)
|
||||
|
||||
# request_confirmation commits by design. Lock and re-check the last-admin
|
||||
# invariant in the deletion transaction before any personal row is removed.
|
||||
user = self._lock_user(code=code)
|
||||
return self._confirm_and_erase(
|
||||
user,
|
||||
confirmation.confirmation_code,
|
||||
audit_source=AuditSource.API,
|
||||
)
|
||||
|
||||
def _find_principal_user(self, principal: FeishuPrincipal) -> FeishuUser:
|
||||
user = self.db.execute(
|
||||
select(FeishuUser).where(
|
||||
FeishuUser.id == principal.owner_id,
|
||||
FeishuUser.code == principal.user_code,
|
||||
FeishuUser.tenant_key == principal.tenant_key,
|
||||
FeishuUser.open_id == principal.open_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=FEISHU_USER_NOT_FOUND,
|
||||
)
|
||||
return user
|
||||
|
||||
def _lock_principal_user(self, principal: FeishuPrincipal) -> FeishuUser:
|
||||
return self._lock_user(
|
||||
owner_id=principal.owner_id,
|
||||
code=principal.user_code,
|
||||
tenant_key=principal.tenant_key,
|
||||
open_id=principal.open_id,
|
||||
)
|
||||
|
||||
def _lock_user(
|
||||
self,
|
||||
*,
|
||||
owner_id: int | None = None,
|
||||
code: str | None = None,
|
||||
tenant_key: str | None = None,
|
||||
open_id: str | None = None,
|
||||
) -> FeishuUser:
|
||||
active_admin_ids = list(
|
||||
self.db.execute(
|
||||
select(FeishuUser.id)
|
||||
.where(
|
||||
FeishuUser.role == FeishuUserRole.ADMIN,
|
||||
FeishuUser.status == FeishuUserStatus.ACTIVE,
|
||||
)
|
||||
.order_by(FeishuUser.id.asc())
|
||||
.with_for_update()
|
||||
).scalars()
|
||||
)
|
||||
filters = []
|
||||
if owner_id is not None:
|
||||
filters.append(FeishuUser.id == owner_id)
|
||||
if code is not None:
|
||||
filters.append(FeishuUser.code == code)
|
||||
if tenant_key is not None:
|
||||
filters.append(FeishuUser.tenant_key == tenant_key)
|
||||
if open_id is not None:
|
||||
filters.append(FeishuUser.open_id == open_id)
|
||||
user = self.db.execute(
|
||||
select(FeishuUser).where(*filters).with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=FEISHU_USER_NOT_FOUND,
|
||||
)
|
||||
if (
|
||||
user.role == FeishuUserRole.ADMIN
|
||||
and user.status == FeishuUserStatus.ACTIVE
|
||||
and active_admin_ids == [user.id]
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=LAST_ACTIVE_ADMIN_ERROR,
|
||||
)
|
||||
return user
|
||||
|
||||
def _confirm_and_erase(
|
||||
self,
|
||||
user: FeishuUser,
|
||||
confirmation_code: str,
|
||||
*,
|
||||
audit_source: str,
|
||||
) -> ErasureResult:
|
||||
identifiers = tuple(
|
||||
sorted(
|
||||
{
|
||||
value
|
||||
for value in (
|
||||
user.code,
|
||||
user.open_id,
|
||||
user.union_id,
|
||||
user.user_id,
|
||||
)
|
||||
if value
|
||||
},
|
||||
key=len,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
configured_admins = parse_admin_identities(
|
||||
getattr(get_settings(), "feishu_admin_identities", ())
|
||||
)
|
||||
bootstrap_identity_hash = (
|
||||
admin_bootstrap_identity_hash(user.tenant_key, user.open_id)
|
||||
if (user.tenant_key, user.open_id) in configured_admins
|
||||
else None
|
||||
)
|
||||
|
||||
def delete_subscriptions(
|
||||
db: Session,
|
||||
owner_id: int,
|
||||
_anonymous_id: str,
|
||||
) -> dict[str, int]:
|
||||
subscription_ids = select(PushSubscription.id).where(
|
||||
PushSubscription.owner_id == owner_id
|
||||
)
|
||||
deliveries = _row_count(
|
||||
db.execute(
|
||||
delete(PushDelivery).where(
|
||||
PushDelivery.subscription_id.in_(subscription_ids)
|
||||
)
|
||||
).rowcount
|
||||
)
|
||||
subscriptions = _row_count(
|
||||
db.execute(
|
||||
delete(PushSubscription).where(
|
||||
PushSubscription.owner_id == owner_id
|
||||
)
|
||||
).rowcount
|
||||
)
|
||||
return {
|
||||
"deliveries": deliveries,
|
||||
"subscriptions": subscriptions,
|
||||
}
|
||||
|
||||
def finalize_identity(
|
||||
db: Session,
|
||||
_owner_id: int,
|
||||
anonymous_id: str,
|
||||
) -> dict[str, int]:
|
||||
# Flush the pending confirmation-request deletion before removing
|
||||
# the identity that owns it.
|
||||
db.flush()
|
||||
anonymized_logs = _anonymize_audit_logs(
|
||||
db,
|
||||
identifiers=identifiers,
|
||||
anonymous_id=anonymous_id,
|
||||
)
|
||||
if bootstrap_identity_hash is not None:
|
||||
existing_tombstone = db.scalar(
|
||||
select(FeishuAdminBootstrapTombstone.id).where(
|
||||
FeishuAdminBootstrapTombstone.identity_hash
|
||||
== bootstrap_identity_hash
|
||||
)
|
||||
)
|
||||
if existing_tombstone is None:
|
||||
db.add(
|
||||
FeishuAdminBootstrapTombstone(
|
||||
identity_hash=bootstrap_identity_hash
|
||||
)
|
||||
)
|
||||
db.delete(user)
|
||||
db.flush()
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor=anonymous_id,
|
||||
source=audit_source,
|
||||
action=PERSONAL_DATA_ERASURE_ACTION,
|
||||
target_type=None,
|
||||
target_id=None,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload=None,
|
||||
response_payload=None,
|
||||
status=AuditStatus.SUCCESS,
|
||||
request_id=None,
|
||||
created_at=utc_now(),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"audit_logs_anonymized": anonymized_logs,
|
||||
"identity": 1,
|
||||
}
|
||||
|
||||
return self.erasure.confirm_and_erase(
|
||||
user.id,
|
||||
confirmation_code,
|
||||
before_hooks=(delete_subscriptions,),
|
||||
extra_hooks=(*self.extra_hooks, finalize_identity),
|
||||
)
|
||||
|
||||
|
||||
def _anonymize_audit_logs(
|
||||
db: Session,
|
||||
*,
|
||||
identifiers: Sequence[str],
|
||||
anonymous_id: str,
|
||||
) -> int:
|
||||
if not identifiers:
|
||||
return 0
|
||||
changed_count = 0
|
||||
for record in db.execute(select(AuditLog)).scalars():
|
||||
searchable_values = (
|
||||
record.actor,
|
||||
record.target_id,
|
||||
record.request_payload,
|
||||
record.response_payload,
|
||||
)
|
||||
if not any(
|
||||
identifier in value
|
||||
for value in searchable_values
|
||||
if value is not None
|
||||
for identifier in identifiers
|
||||
):
|
||||
continue
|
||||
record.actor = anonymous_id
|
||||
record.target_type = None
|
||||
record.target_id = None
|
||||
record.request_payload = None
|
||||
record.response_payload = None
|
||||
record.request_id = None
|
||||
changed_count += 1
|
||||
db.flush()
|
||||
return changed_count
|
||||
|
||||
|
||||
def _row_count(value: int | None) -> int:
|
||||
return max(0, int(value or 0))
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import business_mutations_enabled
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.utils.time import utc_now
|
||||
from app.application.delivery import ReportDeliveryService
|
||||
@@ -66,12 +65,6 @@ class LifecyclePipelineService:
|
||||
force: bool = False,
|
||||
actor: str = ActorValue.SCHEDULER,
|
||||
) -> dict[str, Any]:
|
||||
if not business_mutations_enabled():
|
||||
return {
|
||||
"period_key": self.period_key(report_type),
|
||||
"deduplicated": False,
|
||||
"status": "operations_disabled",
|
||||
}
|
||||
workflow, period_key, deduplicated = self.prepare(report_type, actor, force)
|
||||
if deduplicated:
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.security import business_mutations_enabled
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.market.chart import render_market_chart
|
||||
@@ -51,12 +50,6 @@ class MarketPipelineService:
|
||||
) -> dict[str, Any]:
|
||||
target = reference_date or date.today()
|
||||
period_key = self.period_key(report_type, target)
|
||||
if not business_mutations_enabled():
|
||||
return {
|
||||
"period_key": period_key,
|
||||
"status": "operations_disabled",
|
||||
"deduplicated": False,
|
||||
}
|
||||
existing = self.find(period_key)
|
||||
if existing is not None and existing.status == WorkflowStatus.COMPLETED and not force:
|
||||
return {
|
||||
|
||||
@@ -46,14 +46,23 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
enqueue_market_report,
|
||||
enqueue_project_weekly_push,
|
||||
enqueue_risk_progress_push,
|
||||
enqueue_subscription_cycle,
|
||||
enqueue_work_daily_push,
|
||||
enqueue_work_weekly_push,
|
||||
)
|
||||
from app.modules.observability.service import ObservabilityService
|
||||
from app.modules.personalization.services import ConversationService
|
||||
from app.modules.reports.services import ReportService
|
||||
|
||||
settings = get_settings()
|
||||
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||
scheduler = BackgroundScheduler(
|
||||
timezone="Asia/Shanghai",
|
||||
job_defaults={
|
||||
"coalesce": True,
|
||||
"max_instances": 1,
|
||||
"misfire_grace_time": 300,
|
||||
},
|
||||
)
|
||||
|
||||
def deliver_report(
|
||||
state_key: str,
|
||||
@@ -189,7 +198,19 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if settings.lifecycle_pipeline_enabled and not settings.read_only_mode:
|
||||
def run_subscription_cycle() -> None:
|
||||
dispatch = enqueue_subscription_cycle(actor=ActorValue.SCHEDULER)
|
||||
_set_state(app, "last_subscription_cycle", dispatch)
|
||||
|
||||
def run_personalization_retention_cleanup() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
deleted = ConversationService(db).cleanup_expired_globally()
|
||||
_set_state(app, "last_personalization_retention_cleanup", deleted)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if settings.lifecycle_pipeline_enabled:
|
||||
scheduler.add_job(
|
||||
run_daily_lifecycle,
|
||||
trigger="cron",
|
||||
@@ -266,7 +287,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
id="event_dispatch",
|
||||
replace_existing=True,
|
||||
)
|
||||
if settings.market_analysis_enabled and not settings.read_only_mode:
|
||||
if settings.market_analysis_enabled:
|
||||
scheduler.add_job(
|
||||
run_market_premarket,
|
||||
trigger="cron",
|
||||
@@ -301,9 +322,23 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
id="scheduler_heartbeat",
|
||||
replace_existing=True,
|
||||
)
|
||||
if settings.feishu_user_features_enabled:
|
||||
scheduler.add_job(
|
||||
run_subscription_cycle,
|
||||
trigger="interval",
|
||||
minutes=1,
|
||||
id="subscription_delivery_cycle",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_personalization_retention_cleanup,
|
||||
trigger="interval",
|
||||
minutes=1,
|
||||
id="personalization_retention_cleanup",
|
||||
replace_existing=True,
|
||||
)
|
||||
if (
|
||||
not settings.lifecycle_pipeline_enabled
|
||||
and not settings.read_only_mode
|
||||
and settings.legacy_sync_enabled
|
||||
and settings.legacy_project_query
|
||||
):
|
||||
@@ -317,7 +352,6 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
)
|
||||
if (
|
||||
not settings.lifecycle_pipeline_enabled
|
||||
and not settings.read_only_mode
|
||||
and settings.legacy_sync_enabled
|
||||
and settings.legacy_task_query
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user