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

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

View File

@@ -0,0 +1,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()

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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",
]

View 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,
)

View File

@@ -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 = (

View 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,
)

View File

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

View File

@@ -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

View 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,
)

View 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))

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
):