refactor(Dockerfile): 使用requirements.txt替代硬编码依赖

将Dockerfile中的硬编码pip包列表替换为通过requirements.txt文件安装,
提高依赖管理的灵活性和可维护性。

feat(scheduling): 移除内置APScheduler,采用独立调度系统

移除app/core/background/scheduler.py中原来的APScheduler实现,
改为使用新的应用级调度系统app.application.scheduling。

refactor(task_queue): 调整任务队列模块结构和导入路径

将任务队列相关常量从app.core.background.task_queue.constants迁移至
app.tasks.constants,并更新所有相关导入路径和引用。

refactor(events): 将事件服务重构为独立的应用层组件

将事件分发逻辑从核心层迁移到应用层,使用app.application.events.EventDispatchService
替代原有的app.modules.events.services.EventService。

feat(ai_memory): 增强AI记忆自动写入的安全策略

新增ai_memory_blocked_content_terms配置项用于阻止敏感内容,
添加TTL过期机制控制自动写入条目的生命周期。

fix(security): 强化生产环境安全验证机制

增加model_validator确保生产环境中数据库连接、API密钥、CORS设置等
关键安全配置符合要求。

feat(risks): 优化风险事件操作动作的外键约束

为RiskEventAction模型的风险事件ID字段添加外键约束,
防止孤立记录并增强数据完整性。

refactor(audit): 优化审计服务方法命名和事务处理

将AuditService的log方法重命名为record以反映其阶段行为,
并调整事务提交时机以提高性能。

feat(events): 增强领域事件并发处理和响应模型

添加事件锁定机制防止重复处理,更新API响应模型以提供
更准确的数据类型定义。
```
This commit is contained in:
2026-07-15 16:36:42 +08:00
parent 267b01b9f4
commit db751f03b4
73 changed files with 1615 additions and 933 deletions

View File

@@ -5,24 +5,8 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY environment.yml /app/environment.yml COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir \ RUN pip install --no-cache-dir -r /app/requirements.txt
fastapi==0.115.6 \
"uvicorn[standard]==0.34.0" \
sqlalchemy==2.0.36 \
pymysql==1.1.1 \
"psycopg[binary]==3.2.3" \
pydantic-settings==2.7.1 \
python-dotenv==1.0.1 \
alembic==1.14.0 \
httpx==0.28.1 \
lark-oapi==1.6.8 \
apscheduler==3.10.4 \
redis==5.2.1 \
celery==5.4.0 \
cryptography==44.0.0 \
pandas==2.2.3 \
pillow==11.0.0
COPY alembic.ini /app/alembic.ini COPY alembic.ini /app/alembic.ini
COPY alembic /app/alembic COPY alembic /app/alembic

View File

@@ -0,0 +1,126 @@
"""Harden event, workflow, and owned-ledger constraints.
Revision ID: 202607150001
Revises: 202607120004
Create Date: 2026-07-15
"""
from alembic import op
import sqlalchemy as sa
revision = "202607150001"
down_revision = "202607120004"
branch_labels = None
depends_on = None
def _scalar_count(sql: str) -> int:
return int(op.get_bind().execute(sa.text(sql)).scalar() or 0)
def _assert_owned_relations_are_consistent() -> None:
orphan_risk_actions = _scalar_count(
"""
SELECT COUNT(*)
FROM risk_event_actions action
LEFT JOIN risk_events event ON event.id = action.risk_event_id
WHERE event.id IS NULL
"""
)
if orphan_risk_actions:
raise RuntimeError(
"Cannot add risk-event foreign key: orphan risk_event_actions rows exist"
)
orphan_workflow_actions = _scalar_count(
"""
SELECT COUNT(*)
FROM workflow_actions action
LEFT JOIN workflow_instances workflow ON workflow.code = action.workflow_code
WHERE workflow.code IS NULL
"""
)
if orphan_workflow_actions:
raise RuntimeError(
"Cannot add workflow foreign key: orphan workflow_actions rows exist"
)
duplicate_workflows = _scalar_count(
"""
SELECT COUNT(*)
FROM (
SELECT workflow_type, aggregate_type, aggregate_id
FROM workflow_instances
WHERE aggregate_id IS NOT NULL
GROUP BY workflow_type, aggregate_type, aggregate_id
HAVING COUNT(*) > 1
) duplicates
"""
)
if duplicate_workflows:
raise RuntimeError(
"Cannot add workflow uniqueness constraint: duplicate aggregate workflows exist"
)
def upgrade() -> None:
_assert_owned_relations_are_consistent()
op.execute("UPDATE domain_events SET max_attempts = 3 WHERE max_attempts IS NULL")
with op.batch_alter_table("domain_events") as batch_op:
batch_op.alter_column(
"max_attempts",
existing_type=sa.Integer(),
nullable=False,
server_default=sa.text("3"),
)
with op.batch_alter_table("risk_event_actions") as batch_op:
batch_op.create_foreign_key(
"fk_risk_event_actions_risk_event_id",
"risk_events",
["risk_event_id"],
["id"],
ondelete="RESTRICT",
)
with op.batch_alter_table("workflow_actions") as batch_op:
batch_op.create_foreign_key(
"fk_workflow_actions_workflow_code",
"workflow_instances",
["workflow_code"],
["code"],
ondelete="RESTRICT",
)
with op.batch_alter_table("workflow_instances") as batch_op:
batch_op.create_unique_constraint(
"uq_workflow_aggregate",
["workflow_type", "aggregate_type", "aggregate_id"],
)
def downgrade() -> None:
with op.batch_alter_table("workflow_instances") as batch_op:
batch_op.drop_constraint("uq_workflow_aggregate", type_="unique")
with op.batch_alter_table("workflow_actions") as batch_op:
batch_op.drop_constraint(
"fk_workflow_actions_workflow_code",
type_="foreignkey",
)
with op.batch_alter_table("risk_event_actions") as batch_op:
batch_op.drop_constraint(
"fk_risk_event_actions_risk_event_id",
type_="foreignkey",
)
with op.batch_alter_table("domain_events") as batch_op:
batch_op.alter_column(
"max_attempts",
existing_type=sa.Integer(),
nullable=True,
server_default=None,
)

View File

@@ -0,0 +1 @@
"""Application-level orchestration across domain modules and adapters."""

View File

@@ -0,0 +1,3 @@
from app.application.delivery.reports import ReportDeliveryService
__all__ = ["ReportDeliveryService"]

View File

@@ -1,4 +1,4 @@
from sqlalchemy.orm import Session
from app.modules.audit.constants import AuditAction, AuditSource from app.modules.audit.constants import AuditAction, AuditSource
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
@@ -16,9 +16,16 @@ from app.modules.reports.constants import (
ReportResponseKey, ReportResponseKey,
) )
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
from app.modules.reports.services import ReportService
class ReportDeliveryMixin: class ReportDeliveryService:
"""Deliver generated reports without coupling report generation to Feishu."""
def __init__(self, db: Session):
self.db = db
self.reports = ReportService(db)
def push_report( def push_report(
self, self,
report: dict, report: dict,
@@ -30,9 +37,9 @@ class ReportDeliveryMixin:
report_type = str(report.get(ReportResponseKey.REPORT_TYPE) or report.get("type") or "report") report_type = str(report.get(ReportResponseKey.REPORT_TYPE) or report.get("type") or "report")
title = report.get(ReportResponseKey.TITLE) title = report.get(ReportResponseKey.TITLE)
push_run = ( push_run = (
self._get_push_run(push_run_code) self.reports._get_push_run(push_run_code)
if push_run_code if push_run_code
else self.create_push_run( else self.reports.create_push_run(
report_type=report_type, report_type=report_type,
title=title, title=title,
receive_id=receive_id, receive_id=receive_id,
@@ -59,12 +66,13 @@ class ReportDeliveryMixin:
) )
result = feishu.send_card(card, receive_id, receive_id_type, actor) result = feishu.send_card(card, receive_id, receive_id_type, actor)
except Exception as exc: except Exception as exc:
failed_run = self.update_push_run( failed_run = self.reports.update_push_run(
push_run.code, push_run.code,
ReportPushStatus.FAILED, ReportPushStatus.FAILED,
error_message=str(exc), error_message=str(exc),
commit=False,
) )
EventService(self.db).emit( EventService(self.db).enqueue(
event_type=EventType.REPORT_PUSH_FAILED, event_type=EventType.REPORT_PUSH_FAILED,
source=EventSource.REPORTS, source=EventSource.REPORTS,
aggregate_type=EventAggregateType.REPORT_PUSH_RUN, aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
@@ -77,14 +85,16 @@ class ReportDeliveryMixin:
}, },
idempotency_key=f"report-push:{failed_run.code}:{failed_run.status}", idempotency_key=f"report-push:{failed_run.code}:{failed_run.status}",
) )
self.db.commit()
raise raise
success_run = self.update_push_run( success_run = self.reports.update_push_run(
push_run.code, push_run.code,
ReportPushStatus.SUCCESS, ReportPushStatus.SUCCESS,
provider_response=result, provider_response=result,
sent=True, sent=True,
commit=False,
) )
EventService(self.db).emit( EventService(self.db).enqueue(
event_type=EventType.REPORT_PUSH_SUCCEEDED, event_type=EventType.REPORT_PUSH_SUCCEEDED,
source=EventSource.REPORTS, source=EventSource.REPORTS,
aggregate_type=EventAggregateType.REPORT_PUSH_RUN, aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
@@ -96,7 +106,7 @@ class ReportDeliveryMixin:
}, },
idempotency_key=f"report-push:{success_run.code}:{success_run.status}", idempotency_key=f"report-push:{success_run.code}:{success_run.status}",
) )
AuditService(self.db).log( AuditService(self.db).record(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.REPORTS, source=AuditSource.REPORTS,
@@ -105,4 +115,5 @@ class ReportDeliveryMixin:
response_payload={"status": ReportPushStatus.SUCCESS}, response_payload={"status": ReportPushStatus.SUCCESS},
) )
) )
self.db.commit()
return result return result

View File

@@ -0,0 +1,3 @@
from app.application.events.dispatch import EventDispatchService
__all__ = ["EventDispatchService"]

View File

@@ -5,6 +5,7 @@ from uuid import uuid4
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import or_, select from sqlalchemy import or_, select
from app.application.events.handlers import EventHandlerMixin
from app.core.config import get_settings from app.core.config import get_settings
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.http.pagination import bounded_limit from app.core.http.pagination import bounded_limit
@@ -23,23 +24,42 @@ from app.modules.events.constants import (
EventStatus, EventStatus,
) )
from app.modules.events.models import DomainEvent from app.modules.events.models import DomainEvent
from app.modules.events.services import EventService
from app.modules.events.services.serialization import _serialize_event from app.modules.events.services.serialization import _serialize_event
class EventDispatchMixin: class EventDispatchService(EventHandlerMixin):
def dispatch_event(self, event_id: str, worker_id: str | None = None) -> DomainEvent: """Claim and dispatch outbox events at the application boundary."""
record = self.get_event(event_id)
def __init__(self, db: Any):
self.db = db
self.events = EventService(db)
def get_event(self, event_id: str) -> DomainEvent:
return self.events.get_event(event_id)
def dispatch_event(
self,
event_id: str,
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 record.status == EventStatus.PROCESSED: if record.status == EventStatus.PROCESSED:
return record return record
if not self._can_attempt(record): if preclaimed and record.locked_by != lock_owner:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=EventErrorDetail.EVENT_LOCKED,
)
if not preclaimed and record.locked_by != lock_owner:
return record return record
settings = get_settings() settings = get_settings()
now = utc_now()
lock_owner = worker_id or f"api:{uuid4().hex}"
record.locked_by = lock_owner
record.locked_until = now + timedelta(seconds=settings.event_dispatch_lock_seconds)
record.status = EventStatus.PENDING
record.attempts += 1
try: try:
self._handle_event(record) self._handle_event(record)
except Exception as exc: except Exception as exc:
@@ -93,11 +113,26 @@ class EventDispatchMixin:
) )
.order_by(DomainEvent.id.asc()) .order_by(DomainEvent.id.asc())
.limit(bounded_limit(limit)) .limit(bounded_limit(limit))
.with_for_update(skip_locked=True)
) )
records = list(self.db.execute(stmt).scalars()) records = list(self.db.execute(stmt).scalars())
lock_owner = worker_id or f"worker:{uuid4().hex}" 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 [ return [
_serialize_event(self.dispatch_event(record.event_id, worker_id=lock_owner)) _serialize_event(
self.dispatch_event(
record.event_id,
worker_id=lock_owner,
preclaimed=True,
)
)
for record in records for record in records
] ]
@@ -140,6 +175,38 @@ class EventDispatchMixin:
def _can_attempt(self, record: DomainEvent) -> bool: def _can_attempt(self, record: DomainEvent) -> bool:
return record.attempts < self._max_attempts(record) return record.attempts < self._max_attempts(record)
def _claim_event(self, event_id: str, lock_owner: str) -> DomainEvent:
now = utc_now()
record = self.db.execute(
select(DomainEvent)
.where(DomainEvent.event_id == event_id)
.with_for_update(skip_locked=True)
).scalar_one_or_none()
if record is None:
self.db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=EventErrorDetail.EVENT_LOCKED,
)
if record.status == EventStatus.PROCESSED or not self._can_attempt(record):
self.db.commit()
return record
if record.locked_until is not None and record.locked_until > now:
self.db.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=EventErrorDetail.EVENT_LOCKED,
)
record.locked_by = lock_owner
record.locked_until = now + timedelta(
seconds=get_settings().event_dispatch_lock_seconds
)
record.status = EventStatus.PENDING
record.attempts += 1
self.db.commit()
self.db.refresh(record)
return record
@staticmethod @staticmethod
def _max_attempts(record: DomainEvent) -> int: def _max_attempts(record: DomainEvent) -> int:
return record.max_attempts or get_settings().event_dispatch_max_attempts return record.max_attempts or get_settings().event_dispatch_max_attempts

View File

@@ -1,5 +1,3 @@
from app.modules.events.constants import ( from app.modules.events.constants import (
EventAggregateType, EventAggregateType,
EventPayloadKey, EventPayloadKey,
@@ -54,6 +52,7 @@ class EventHandlerMixin:
action=action or record.event_type, action=action or record.event_type,
actor=record.actor, actor=record.actor,
payload=payload, payload=payload,
commit=False,
) )
def _handle_report_event(self, record: DomainEvent) -> None: def _handle_report_event(self, record: DomainEvent) -> None:
@@ -125,4 +124,5 @@ class EventHandlerMixin:
action=record.event_type, action=record.event_type,
actor=record.actor, actor=record.actor,
payload=record.payload or {}, payload=record.payload or {},
commit=False,
) )

View File

@@ -0,0 +1,4 @@
from app.application.feishu.commands import FeishuCommandService
from app.application.feishu.events import FeishuEventService
__all__ = ["FeishuCommandService", "FeishuEventService"]

View File

@@ -0,0 +1,201 @@
import json
import re
from typing import Any
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_finance_command,
handle_market_command,
handle_rule_command,
)
from app.application.feishu.results import command_result
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.feishu.constants import (
FEISHU_AI_REPLY_TITLE,
FEISHU_MENTION_PATTERN,
FEISHU_ZERO_WIDTH_SPACE,
FeishuCommandKey,
FeishuCommandName,
FeishuPayloadKey,
FeishuReplyType,
)
from app.modules.feishu.service import FeishuService
from app.modules.reports.constants import ReportResponseKey
from app.modules.reports.services import ReportService
DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报")
PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报")
ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance")
RISK_KEYWORDS = ("风险", "预警", "risk")
AI_COMMAND_PREFIXES = ("", "ai ", "AI ", "/ask ")
DEFAULT_AI_PROMPT = "请说明你能做什么。"
def _parse_content_text(content: Any) -> str:
"""Extract plain command text from a Feishu message content payload."""
if isinstance(content, dict):
return str(
content.get(FeishuPayloadKey.TEXT) or content.get(FeishuPayloadKey.CONTENT) or ""
)
if not isinstance(content, str):
return ""
try:
data = json.loads(content)
except json.JSONDecodeError:
return content
if isinstance(data, dict):
return str(data.get(FeishuPayloadKey.TEXT) or data.get(FeishuPayloadKey.CONTENT) or "")
return content
def _clean_command_text(text: str) -> str:
"""Remove mentions and invisible characters from Feishu command text."""
text = re.sub(FEISHU_MENTION_PATTERN, "", text or "")
text = text.replace(FEISHU_ZERO_WIDTH_SPACE, "")
return text.strip()
class FeishuCommandService:
"""Route Feishu text commands to focused application handlers."""
def __init__(self, db: Session):
self.db = db
self.feishu = FeishuService(db)
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
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:
return None
sender = event.get(FeishuPayloadKey.SENDER) or {}
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
actor = (
sender_id.get(FeishuPayloadKey.OPEN_ID)
or sender_id.get(FeishuPayloadKey.USER_ID)
or ActorValue.FEISHU
)
return {
FeishuCommandKey.TEXT: text,
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
FeishuCommandKey.ACTOR: actor,
}
def handle_text(
self,
text: str,
chat_id: str | None = None,
actor: str = ActorValue.FEISHU,
auto_reply: bool = True,
) -> dict[str, Any]:
command_text = _clean_command_text(text)
lowered = command_text.lower()
for handler in (
handle_rule_command,
handle_finance_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)
def _handle_report_command(
self,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
service = ReportService(self.db)
if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
command = FeishuCommandName.DAILY_BRIEF
report = service.daily_brief()
elif any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS):
command = FeishuCommandName.PROJECT_WEEKLY
report = service.project_weekly()
elif any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS):
command = FeishuCommandName.ATTENDANCE_SUMMARY
report = service.attendance_summary()
elif any(keyword in command_text for keyword in RISK_KEYWORDS):
command = FeishuCommandName.RISK_SUMMARY
report = service.risk_progress()
else:
return None
response = (
send_card_if_configured(
self.feishu,
chat_id,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
actor,
)
if auto_reply
else None
)
return command_result(
command,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
response,
report[ReportResponseKey.LINES],
)
def _handle_ai_command(
self,
command_text: str,
lowered: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any]:
prompt = command_text
for prefix in AI_COMMAND_PREFIXES:
if command_text.startswith(prefix):
prompt = command_text[len(prefix) :].strip()
break
if not prompt:
prompt = DEFAULT_AI_PROMPT
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)
for prefix in AI_COMMAND_PREFIXES
)
response = (
send_text_if_configured(self.feishu, chat_id, content, actor) if auto_reply else None
)
return command_result(
FeishuCommandName.AI_ASK if is_explicit_ai else FeishuCommandName.FALLBACK_AI,
FeishuReplyType.TEXT,
FEISHU_AI_REPLY_TITLE,
content,
response,
)

View File

@@ -0,0 +1,34 @@
from typing import Any
from app.core.config import get_settings
from app.modules.feishu.service import FeishuService
def send_text_if_configured(
feishu: FeishuService,
chat_id: str | None,
text: str,
actor: str,
) -> 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)
def send_card_if_configured(
feishu: FeishuService,
chat_id: str | None,
title: str,
lines: list[str],
actor: str,
) -> dict[str, Any] | None:
"""Send a basic card only when Feishu credentials are configured."""
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
card = FeishuService.build_basic_card(title, lines)
return feishu.send_card(card, receive_id=chat_id, actor=actor)

View File

@@ -3,10 +3,10 @@ from typing import Any
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.application.feishu.commands import FeishuCommandService
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.modules.audit.constants import AuditAction, AuditSource from app.modules.audit.constants import AuditAction, AuditSource
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.constants import ( from app.modules.feishu.constants import (
FeishuCommandKey, FeishuCommandKey,
FeishuEventReceiptKey, FeishuEventReceiptKey,
@@ -60,7 +60,7 @@ class FeishuEventService:
if event_identity if event_identity
else None else None
), ),
request_payload=payload, request_payload=_audit_event_metadata(payload),
response_payload={FeishuResponseKey.ACCEPTED: True}, response_payload={FeishuResponseKey.ACCEPTED: True},
) )
) )
@@ -95,6 +95,25 @@ class FeishuEventService:
return True return True
def _audit_event_metadata(payload: dict[str, Any]) -> dict[str, Any]:
"""Keep webhook audit evidence without storing message content or tokens."""
header = payload.get(FeishuPayloadKey.HEADER) or {}
event = payload.get(FeishuPayloadKey.EVENT) or {}
message = event.get(FeishuPayloadKey.MESSAGE) or {}
sender = event.get(FeishuPayloadKey.SENDER) or {}
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
return {
"schema": payload.get("schema"),
FeishuPayloadKey.EVENT_ID: header.get(FeishuPayloadKey.EVENT_ID),
FeishuPayloadKey.EVENT_TYPE: header.get(FeishuPayloadKey.EVENT_TYPE),
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),
}
def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource: def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource:
return FeishuEventSource(source) return FeishuEventSource(source)

View File

@@ -0,0 +1,9 @@
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
__all__ = [
"handle_finance_command",
"handle_market_command",
"handle_rule_command",
]

View File

@@ -0,0 +1,141 @@
import re
from typing import Any
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.core.config import get_settings
from app.modules.ai_agent.constants import AIResponseKey
from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType
from app.modules.feishu.service import FeishuService
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
from app.modules.reports.constants import ReportResponseKey
from app.modules.reports.services import ReportService
PROJECT_FINANCE_PATTERN = re.compile(r"^项目资金\s+(.+)$")
FINANCE_COMMANDS = {"资金需求", "未来30天资金需求"}
def handle_finance_command(
db: Session,
feishu: FeishuService,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
"""Handle project cash-needs commands."""
project_match = PROJECT_FINANCE_PATTERN.fullmatch(command_text)
if command_text not in FINANCE_COMMANDS and project_match is None:
return None
command = (
FeishuCommandName.PROJECT_FINANCE if project_match else FeishuCommandName.FINANCE_NEEDS
)
if not get_settings().finance_needs_enabled:
return _text_result(
feishu,
command,
"项目资金需求分析",
"项目资金需求分析尚未启用,请先配置并启用财务只读同步。",
chat_id,
actor,
auto_reply,
)
project_code = project_match.group(1).strip() if project_match else None
service = ReportService(db)
preview = service.project_finance_needs_report(
project_code=project_code,
include_ai=False,
actor=actor,
)
if project_code and not preview["items"]:
return _text_result(
feishu,
command,
"项目资金需求分析",
f"未找到项目“{project_code}”,请使用稳定项目编号或展示编号。",
chat_id,
actor,
auto_reply,
)
if not preview["summary"]["data_available"]:
return _text_result(
feishu,
command,
"项目资金需求分析",
"项目财务数据未接入或无有效记录,暂不生成资金分析报告。",
chat_id,
actor,
auto_reply,
)
report = service.project_finance_needs_report(
project_code=project_code,
include_ai=True,
actor=actor,
)
ai_analysis = report.get("ai_analysis") or {}
if not ai_analysis.get(AIResponseKey.OK):
return _text_result(
feishu,
command,
"AI 暂不可用",
"AI 当前不可用,本次项目资金分析报告未发送。请检查模型服务。",
chat_id,
actor,
auto_reply,
)
provider_response = None
if auto_reply:
provider_response = _send_finance_card(feishu, chat_id, report, actor)
return command_result(
command,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
def _text_result(
feishu: FeishuService,
command: FeishuCommandName,
title: str,
content: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any]:
response = send_text_if_configured(feishu, chat_id, content, actor) if auto_reply else None
return command_result(command, FeishuReplyType.TEXT, title, content, response)
def _send_finance_card(
feishu: FeishuService,
chat_id: str | None,
report: dict[str, Any],
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
chart_data = {
"period": report.get("as_of"),
"finance": report.get("finance_chart_data"),
}
image_result = feishu.upload_image(render_lifecycle_chart(chart_data), actor)
image_key = (image_result.get("data") or {}).get("image_key")
if not image_key:
raise ValueError("Feishu image upload did not return image_key")
card = FeishuService.build_basic_card(
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
image_key=image_key,
image_alt=lifecycle_chart_alt(chart_data),
)
return feishu.send_card(card, receive_id=chat_id, actor=actor)

View File

@@ -0,0 +1,243 @@
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.core.config import get_settings
from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType
from app.modules.feishu.service import FeishuService
from app.modules.market.chart import render_market_chart
from app.modules.market.service import MarketService
STOCK_ANALYSIS_PATTERN = re.compile(
r"^(?:股票分析|估值分析|财报分析)\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$", re.IGNORECASE
)
WATCHLIST_ADD_PATTERN = re.compile(r"^加入自选\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$", re.IGNORECASE)
MARKET_COMMANDS = {
"市场分析",
"今日收盘分析",
"本周市场分析",
"宏观金融分析",
"最新公告",
}
INDUSTRY_ANALYSIS_PATTERN = re.compile(r"^行业分析\s+(.+)$")
STOCK_COMPARE_PATTERN = re.compile(
r"^股票对比\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)\s+"
r"([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$",
re.IGNORECASE,
)
def handle_market_command(
db: Session,
feishu: FeishuService,
text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
"""Handle market analysis and watchlist commands."""
stock = STOCK_ANALYSIS_PATTERN.fullmatch(text)
add = WATCHLIST_ADD_PATTERN.fullmatch(text)
industry = INDUSTRY_ANALYSIS_PATTERN.fullmatch(text)
comparison = STOCK_COMPARE_PATTERN.fullmatch(text)
if (
text not in MARKET_COMMANDS
and text != "查看自选"
and not stock
and not add
and not industry
and not comparison
):
return None
if not get_settings().market_analysis_enabled:
return _text_result(
feishu,
FeishuCommandName.MARKET_OVERVIEW,
"市场分析",
"市场分析尚未启用,请配置市场数据源后启用。",
chat_id,
actor,
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 = (
"最新公告:\n"
+ "\n".join(
f"- {item['announcement_date']} {item['symbol'] or '市场'}{item['title']}"
for item in items
)
if items
else "公告元数据尚未接入。"
)
return _text_result(
feishu,
FeishuCommandName.MARKET_ANNOUNCEMENTS,
"最新公告",
content,
chat_id,
actor,
auto_reply,
)
if industry:
try:
data = service.industry_analysis(industry.group(1).strip())
content = (
f"{data['industry']} 平均涨跌 {data['average_pct_change']}%\n"
+ "\n".join(
f"- {item['name']}{item['symbol']}{item['pct_change']}%"
for item in data["items"][:10]
)
)
except HTTPException:
content = "未找到该行业的最新市场数据。"
return _text_result(
feishu,
FeishuCommandName.MARKET_OVERVIEW,
"行业分析",
content,
chat_id,
actor,
auto_reply,
)
if comparison:
try:
content = service.compare_stocks([comparison.group(1), comparison.group(2)])["content"]
except HTTPException:
content = "至少一只股票缺少可用行情,暂时无法比较。"
return _text_result(
feishu,
FeishuCommandName.STOCK_ANALYSIS,
"股票对比",
content,
chat_id,
actor,
auto_reply,
)
return _analysis_result(service, feishu, text, stock.group(1) if stock else None, chat_id, actor, auto_reply)
def _analysis_result(
service: MarketService,
feishu: FeishuService,
text: str,
symbol: str | None,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any]:
command = FeishuCommandName.STOCK_ANALYSIS if symbol else FeishuCommandName.MARKET_OVERVIEW
if text == "宏观金融分析":
command = FeishuCommandName.MARKET_MACRO
try:
if symbol:
report = service.stock_analysis(symbol, True, actor)
elif text == "本周市场分析":
report = service.weekly_overview(include_ai=True, actor=actor)
elif text == "宏观金融分析":
report = service.macro_analysis(include_ai=True, actor=actor)
else:
report = service.market_overview(include_ai=True, actor=actor)
except HTTPException:
return _text_result(
feishu,
command,
"股票分析",
"未找到该股票的可用行情,请确认代码或先执行行情同步。",
chat_id,
actor,
auto_reply,
)
ai = report.get("ai_analysis") or {}
if not report.get("data_available"):
content = "市场数据未接入,暂不生成分析报告。"
elif not ai.get("ok"):
content = "AI 当前不可用,本次市场分析报告未发送。"
else:
content = report["content"]
response = _send_analysis(feishu, text, report, content, chat_id, actor) if auto_reply else None
return command_result(
command,
FeishuReplyType.CARD if ai.get("ok") and text != "宏观金融分析" else FeishuReplyType.TEXT,
report["title"],
content,
response,
report["lines"] if ai.get("ok") else None,
)
def _send_analysis(
feishu: FeishuService,
text: str,
report: dict[str, Any],
content: str,
chat_id: str | None,
actor: str,
) -> dict[str, Any] | None:
ai = report.get("ai_analysis") or {}
if not ai.get("ok") or text == "宏观金融分析":
return send_text_if_configured(feishu, chat_id, content, actor)
image = feishu.upload_image(render_market_chart(report), actor)
image_key = (image.get("data") or {}).get("image_key")
if not image_key:
raise ValueError("Feishu image upload did not return image_key")
card = FeishuService.build_basic_card(
report["title"],
report["lines"],
image_key=image_key,
image_alt=report["title"],
)
return feishu.send_card(card, receive_id=chat_id, actor=actor)
def _text_result(
feishu: FeishuService,
command: FeishuCommandName,
title: str,
content: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any]:
response = send_text_if_configured(feishu, chat_id, content, actor) if auto_reply else None
return command_result(command, FeishuReplyType.TEXT, title, content, response)

View File

@@ -0,0 +1,192 @@
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.core.config import get_settings
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
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_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_LIST_COMMANDS = {"查看规则", "规则列表", "查看市场规则"}
RULE_COMMAND_PREFIXES = (
"学习市场规则",
"学习规则",
"查看市场规则",
"查看规则",
"规则列表",
"停用规则",
"启用规则",
)
RULE_COMMAND_HELP = (
"规则指令格式:\n"
"学习规则:<规则内容>\n"
"学习规则 80<规则内容>\n"
"学习市场规则 80<仅用于市场分析的规则内容>\n"
"查看规则\n"
"停用规则 <规则编号>\n"
"启用规则 <规则编号>"
)
def handle_rule_command(
db: Session,
feishu: FeishuService,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
"""Handle 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,
)
content = RULE_COMMAND_HELP
try:
command, content = _execute(db, command_text, command, actor)
except HTTPException as exc:
detail = str(exc.detail)
if "secret-like" in detail:
content = "规则疑似包含密码、令牌或其他密钥信息,已拒绝学习。"
elif exc.status_code == 404:
content = "没有找到该规则,请先发送“查看规则”确认规则编号。"
elif "priority" in detail:
content = "规则优先级必须在 1 到 100 之间。"
else:
content = "规则未保存,请检查指令内容后重试。"
return _result(feishu, command, content, chat_id, actor, auto_reply)
def _execute(
db: Session,
command_text: str,
command: FeishuCommandName,
actor: str,
) -> 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)
disable_match = RULE_DISABLE_PATTERN.fullmatch(command_text)
enable_match = RULE_ENABLE_PATTERN.fullmatch(command_text)
memory = AIMemoryService(db)
if create_match:
return command, _create_rule(memory, create_match, market_create_match is not None, actor)
if command_text in RULE_LIST_COMMANDS:
return FeishuCommandName.RULE_LIST, _list_rules(memory, command_text)
if disable_match or enable_match:
enabled = enable_match is not None
match = enable_match or disable_match
rule = memory.update_rule(
code=match.group(1),
content=None,
priority=None,
tags=None,
enabled=enabled,
actor=actor,
)
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}",
)
return command, RULE_COMMAND_HELP
def _create_rule(
memory: AIMemoryService,
match: re.Match[str],
market_rule: bool,
actor: str,
) -> str:
priority = int(match.group(1) or 50)
content = match.group(2).strip()
if not content:
return f"规则内容不能为空。\n\n{RULE_COMMAND_HELP}"
if not 1 <= priority <= 100:
return "规则优先级必须在 1 到 100 之间。"
rule = memory.create_rule(
content=content,
scope="market" if market_rule else "global",
subject="market" if market_rule else "company",
priority=priority,
tags=["feishu", *(["market"] if market_rule else [])],
actor=actor,
)
return (
"规则已学习。\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
"状态:已启用"
)
def _list_rules(memory: AIMemoryService, command_text: str) -> str:
rules = memory.list_rules(
scope="market" if command_text == "查看市场规则" else None,
status_filter=AIMemoryStatus.ACTIVE,
limit=20,
)
if not rules:
return "当前没有已启用的学习规则。"
lines = ["当前已启用的学习规则:"]
for rule in rules:
rule_text = str(rule["content"])
if len(rule_text) > 80:
rule_text = f"{rule_text[:80]}"
lines.append(
f"{rule['code']}|优先级 {rule['importance']}"
f"{rule['scope']}/{rule['subject']}\n{rule_text}"
)
return "\n\n".join(lines)
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_LIST
return FeishuCommandName.RULE_CREATE
def _result(
feishu: FeishuService,
command: FeishuCommandName,
content: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any]:
response = send_text_if_configured(feishu, chat_id, content, actor) if auto_reply else None
return command_result(command, FeishuReplyType.TEXT, RULE_TITLE, content, response)

View File

@@ -0,0 +1,29 @@
from typing import Any
from app.modules.feishu.constants import (
FeishuCommandName,
FeishuCommandResultKey,
FeishuReplyType,
)
def command_result(
command: FeishuCommandName,
reply_type: FeishuReplyType,
title: str,
content: str,
provider_response: dict[str, Any] | None = None,
lines: list[str] | None = None,
) -> dict[str, Any]:
"""Build the stable command response contract."""
result: dict[str, Any] = {
FeishuCommandResultKey.COMMAND: command,
FeishuCommandResultKey.REPLY_TYPE: reply_type,
FeishuCommandResultKey.TITLE: title,
FeishuCommandResultKey.CONTENT: content,
FeishuCommandResultKey.PROVIDER_RESPONSE: provider_response,
}
if lines is not None:
result[FeishuCommandResultKey.LINES] = lines
return result

View File

@@ -0,0 +1,4 @@
from app.application.pipelines.lifecycle import LifecyclePipelineService
from app.application.pipelines.market import MarketPipelineService
__all__ = ["LifecyclePipelineService", "MarketPipelineService"]

View File

@@ -5,8 +5,10 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.core.security import business_mutations_enabled
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.utils.time import utc_now from app.core.utils.time import utc_now
from app.application.delivery import ReportDeliveryService
from app.modules.feishu.service import FeishuService from app.modules.feishu.service import FeishuService
from app.modules.legacy_mysql.intasect import IntasectSyncService from app.modules.legacy_mysql.intasect import IntasectSyncService
from app.modules.reports.constants import ReportPushStatus, ReportType from app.modules.reports.constants import ReportPushStatus, ReportType
@@ -64,7 +66,7 @@ class LifecyclePipelineService:
force: bool = False, force: bool = False,
actor: str = ActorValue.SCHEDULER, actor: str = ActorValue.SCHEDULER,
) -> dict[str, Any]: ) -> dict[str, Any]:
if get_settings().read_only_mode: if not business_mutations_enabled():
return { return {
"period_key": self.period_key(report_type), "period_key": self.period_key(report_type),
"deduplicated": False, "deduplicated": False,
@@ -153,7 +155,7 @@ class LifecyclePipelineService:
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
) )
if push_run.status != ReportPushStatus.SUCCESS: if push_run.status != ReportPushStatus.SUCCESS:
report_service.push_report( ReportDeliveryService(self.db).push_report(
report, report,
target_receive_id, target_receive_id,
receive_id_type, receive_id_type,

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.security import business_mutations_enabled
from app.core.utils.time import utc_now from app.core.utils.time import utc_now
from app.modules.feishu.service import FeishuService from app.modules.feishu.service import FeishuService
from app.modules.market.chart import render_market_chart from app.modules.market.chart import render_market_chart
@@ -50,7 +51,7 @@ class MarketPipelineService:
) -> dict[str, Any]: ) -> dict[str, Any]:
target = reference_date or date.today() target = reference_date or date.today()
period_key = self.period_key(report_type, target) period_key = self.period_key(report_type, target)
if get_settings().read_only_mode: if not business_mutations_enabled():
return { return {
"period_key": period_key, "period_key": period_key,
"status": "operations_disabled", "status": "operations_disabled",

View File

@@ -0,0 +1,3 @@
from app.application.scheduling.scheduler import attach_scheduler, create_scheduler
__all__ = ["attach_scheduler", "create_scheduler"]

View File

@@ -8,6 +8,7 @@ from fastapi import FastAPI
from app.core.config import get_settings from app.core.config import get_settings
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.modules.feishu.constants import FeishuReceiveIdType from app.modules.feishu.constants import FeishuReceiveIdType
from app.application.delivery import ReportDeliveryService
from app.modules.observability.constants import HeartbeatComponent from app.modules.observability.constants import HeartbeatComponent
@@ -74,7 +75,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
) )
_set_state(app, f"{state_key}_dispatch", dispatch) _set_state(app, f"{state_key}_dispatch", dispatch)
return return
service.push_report( ReportDeliveryService(db).push_report(
report, report,
receive_id=settings.feishu_default_chat_id, receive_id=settings.feishu_default_chat_id,
receive_id_type=FeishuReceiveIdType.CHAT_ID, receive_id_type=FeishuReceiveIdType.CHAT_ID,
@@ -188,7 +189,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
finally: finally:
db.close() db.close()
if settings.lifecycle_pipeline_enabled: if settings.lifecycle_pipeline_enabled and not settings.read_only_mode:
scheduler.add_job( scheduler.add_job(
run_daily_lifecycle, run_daily_lifecycle,
trigger="cron", trigger="cron",
@@ -265,7 +266,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
id="event_dispatch", id="event_dispatch",
replace_existing=True, replace_existing=True,
) )
if settings.market_analysis_enabled: if settings.market_analysis_enabled and not settings.read_only_mode:
scheduler.add_job( scheduler.add_job(
run_market_premarket, run_market_premarket,
trigger="cron", trigger="cron",
@@ -302,6 +303,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
) )
if ( if (
not settings.lifecycle_pipeline_enabled not settings.lifecycle_pipeline_enabled
and not settings.read_only_mode
and settings.legacy_sync_enabled and settings.legacy_sync_enabled
and settings.legacy_project_query and settings.legacy_project_query
): ):
@@ -315,6 +317,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
) )
if ( if (
not settings.lifecycle_pipeline_enabled not settings.lifecycle_pipeline_enabled
and not settings.read_only_mode
and settings.legacy_sync_enabled and settings.legacy_sync_enabled
and settings.legacy_task_query and settings.legacy_task_query
): ):

View File

@@ -1,4 +1,4 @@
from app.core.background.task_queue.constants import ( from app.tasks.constants import (
TASK_DISPATCH_PENDING_EVENTS, TASK_DISPATCH_PENDING_EVENTS,
TASK_GENERATE_RISK_EVENTS, TASK_GENERATE_RISK_EVENTS,
TASK_PUSH_ATTENDANCE_SUMMARY, TASK_PUSH_ATTENDANCE_SUMMARY,

View File

@@ -1,7 +1,8 @@
from typing import Any from typing import Any
from app.application.events import EventDispatchService
from app.core.config import get_settings from app.core.config import get_settings
from app.core.background.task_queue.constants import ( from app.tasks.constants import (
TASK_DISPATCH_PENDING_EVENTS, TASK_DISPATCH_PENDING_EVENTS,
) )
from app.core.background.task_queue.dispatcher import dispatch_task from app.core.background.task_queue.dispatcher import dispatch_task
@@ -13,11 +14,10 @@ def enqueue_event_dispatch(
) -> dict[str, Any]: ) -> dict[str, Any]:
def inline() -> Any: def inline() -> Any:
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.modules.events.services import EventService
db = SessionLocal() db = SessionLocal()
try: try:
return EventService(db).dispatch_pending( return EventDispatchService(db).dispatch_pending(
limit=limit or get_settings().event_dispatch_batch_size, limit=limit or get_settings().event_dispatch_batch_size,
worker_id=actor, worker_id=actor,
) )

View File

@@ -1,6 +1,6 @@
from typing import Any from typing import Any
from app.core.background.task_queue.constants import ( from app.tasks.constants import (
TASK_SYNC_LEGACY_PROJECTS, TASK_SYNC_LEGACY_PROJECTS,
TASK_SYNC_LEGACY_TASKS, TASK_SYNC_LEGACY_TASKS,
) )

View File

@@ -1,10 +1,10 @@
from typing import Any from typing import Any
from app.core.background.task_queue.constants import TASK_RUN_LIFECYCLE from app.tasks.constants import TASK_RUN_LIFECYCLE
from app.core.config import get_settings from app.core.config import get_settings
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService from app.application.pipelines import LifecyclePipelineService
def enqueue_lifecycle_report( def enqueue_lifecycle_report(

View File

@@ -1,7 +1,7 @@
from datetime import date from datetime import date
from typing import Any from typing import Any
from app.core.background.task_queue.constants import TASK_RUN_MARKET_REPORT from app.tasks.constants import TASK_RUN_MARKET_REPORT
from app.core.config import get_settings from app.core.config import get_settings

View File

@@ -1,7 +1,7 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from app.core.background.task_queue.constants import ( from app.tasks.constants import (
TASK_PUSH_ATTENDANCE_SUMMARY, TASK_PUSH_ATTENDANCE_SUMMARY,
TASK_PUSH_DAILY_BRIEF, TASK_PUSH_DAILY_BRIEF,
TASK_PUSH_PROJECT_WEEKLY, TASK_PUSH_PROJECT_WEEKLY,
@@ -135,6 +135,7 @@ def _enqueue_report_push(
) )
def inline() -> Any: def inline() -> Any:
from app.application.delivery import ReportDeliveryService
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.modules.reports.services import ReportService from app.modules.reports.services import ReportService
@@ -142,7 +143,9 @@ def _enqueue_report_push(
try: try:
service = ReportService(db) service = ReportService(db)
report = build_report(service, actor) report = build_report(service, actor)
return service.push_report(report, receive_id, receive_id_type, actor) return ReportDeliveryService(db).push_report(
report, receive_id, receive_id_type, actor
)
finally: finally:
db.close() db.close()

View File

@@ -1,6 +1,6 @@
from typing import Any from typing import Any
from app.core.background.task_queue.constants import ( from app.tasks.constants import (
TASK_GENERATE_RISK_EVENTS, TASK_GENERATE_RISK_EVENTS,
) )
from app.core.background.task_queue.dispatcher import dispatch_task from app.core.background.task_queue.dispatcher import dispatch_task

View File

@@ -2,7 +2,7 @@ import json
from functools import lru_cache from functools import lru_cache
from typing import Annotated, Any from typing import Annotated, Any
from pydantic import Field, field_validator from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
from app.core.constants import ( from app.core.constants import (
@@ -112,6 +112,25 @@ class Settings(BaseSettings):
ai_memory_enabled: bool = True ai_memory_enabled: bool = True
ai_memory_auto_write_enabled: bool = True ai_memory_auto_write_enabled: bool = True
ai_memory_recall_limit: int = 5 ai_memory_recall_limit: int = 5
ai_memory_auto_write_ttl_days: int = Field(default=90, ge=1, le=3650)
ai_memory_blocked_content_terms: list[str] = Field(
default_factory=lambda: [
"bank account",
"budget",
"cash flow",
"financial statement",
"id card",
"payment",
"salary",
"成本",
"付款",
"工资",
"收款",
"现金流",
"财务",
"预算",
]
)
ai_analysis_max_attempts: int = 3 ai_analysis_max_attempts: int = 3
ai_memory_forbidden_keys: list[str] = Field( ai_memory_forbidden_keys: list[str] = Field(
default_factory=lambda: [ default_factory=lambda: [
@@ -151,6 +170,7 @@ class Settings(BaseSettings):
"openclaw_allowed_tools", "openclaw_allowed_tools",
"openclaw_allowed_actions", "openclaw_allowed_actions",
"ai_memory_forbidden_keys", "ai_memory_forbidden_keys",
"ai_memory_blocked_content_terms",
mode="before", mode="before",
) )
@classmethod @classmethod
@@ -216,6 +236,27 @@ class Settings(BaseSettings):
return {str(key): str(item) for key, item in data.items()} return {str(key): str(item) for key, item in data.items()}
raise ValueError(ConfigErrorDetail.LEGACY_ALLOWED_QUERIES_FORMAT) raise ValueError(ConfigErrorDetail.LEGACY_ALLOWED_QUERIES_FORMAT)
@model_validator(mode="after")
def validate_production_safety(self) -> "Settings":
if self.app_env.lower() not in {"prod", "production"}:
return self
errors: list[str] = []
if self.database_url.startswith("sqlite"):
errors.append("DATABASE_URL must use PostgreSQL in production")
if not self.api_key and not any(item.get("key") for item in self.api_keys):
errors.append("API_KEY or API_KEYS is required in production")
if not self.audit_api_key and not any(
item.get("key") for item in self.audit_api_keys
):
errors.append("AUDIT_API_KEY or AUDIT_API_KEYS is required in production")
if "*" in self.cors_origins:
errors.append("CORS_ORIGINS cannot contain '*' in production")
if self.debug:
errors.append("DEBUG must be false in production")
if errors:
raise ValueError("; ".join(errors))
return self
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -0,0 +1,12 @@
from typing import Any
from fastapi.responses import JSONResponse
from app.core.http.masking import mask_configured
class MaskedJSONResponse(JSONResponse):
"""Apply baseline sensitive-field masking to every JSON response."""
def render(self, content: Any) -> bytes:
return super().render(mask_configured(content))

View File

@@ -3,10 +3,18 @@ from app.core.security.operation_guard import (
READ_ONLY_OPERATION_DISABLED, READ_ONLY_OPERATION_DISABLED,
require_operations_enabled, require_operations_enabled,
) )
from app.core.security.operation_policy import (
OperationsDisabledError,
business_mutations_enabled,
ensure_business_mutations_enabled,
)
__all__ = [ __all__ = [
"ApiPrincipal", "ApiPrincipal",
"OperationsDisabledError",
"READ_ONLY_OPERATION_DISABLED", "READ_ONLY_OPERATION_DISABLED",
"business_mutations_enabled",
"ensure_business_mutations_enabled",
"require_api_key", "require_api_key",
"require_audit_api_key", "require_audit_api_key",
"require_operations_enabled", "require_operations_enabled",

View File

@@ -1,6 +1,6 @@
from fastapi import HTTPException, status from fastapi import HTTPException, status
from app.core.config import get_settings from app.core.security.operation_policy import business_mutations_enabled
READ_ONLY_OPERATION_DISABLED = "This service is read-only; data mutation operations are disabled" READ_ONLY_OPERATION_DISABLED = "This service is read-only; data mutation operations are disabled"
@@ -9,7 +9,7 @@ READ_ONLY_OPERATION_DISABLED = "This service is read-only; data mutation operati
def require_operations_enabled(detail: str = READ_ONLY_OPERATION_DISABLED) -> None: def require_operations_enabled(detail: str = READ_ONLY_OPERATION_DISABLED) -> None:
"""Reject mutation-oriented endpoints when the product is running read-only.""" """Reject mutation-oriented endpoints when the product is running read-only."""
if get_settings().read_only_mode: if not business_mutations_enabled():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail=detail, detail=detail,

View File

@@ -0,0 +1,20 @@
from app.core.config import get_settings
class OperationsDisabledError(RuntimeError):
"""Raised when a business mutation is attempted in read-only mode."""
def business_mutations_enabled() -> bool:
"""Return whether platform-owned business ledgers may be mutated."""
return not get_settings().read_only_mode
def ensure_business_mutations_enabled() -> None:
"""Enforce read-only policy outside the HTTP transport layer."""
if not business_mutations_enabled():
raise OperationsDisabledError(
"Business mutations are disabled while READ_ONLY_MODE is enabled"
)

View File

@@ -4,7 +4,8 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router from app.api.router import api_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.http.middleware import request_id_middleware from app.core.http.middleware import request_id_middleware
from app.core.background.scheduler import attach_scheduler from app.core.http.responses import MaskedJSONResponse
from app.application.scheduling import attach_scheduler
def _allow_cors_credentials(cors_origins: list[str]) -> bool: def _allow_cors_credentials(cors_origins: list[str]) -> bool:
@@ -23,6 +24,7 @@ def create_app() -> FastAPI:
"AI integration layer for company lifecycle management, existing MySQL " "AI integration layer for company lifecycle management, existing MySQL "
"project systems, Feishu, OpenClaw, Hermes, and model providers." "project systems, Feishu, OpenClaw, Hermes, and model providers."
), ),
default_response_class=MaskedJSONResponse,
) )
app.middleware("http")(request_id_middleware) app.middleware("http")(request_id_middleware)

View File

@@ -48,6 +48,7 @@ class AIMemoryText(StrEnum):
DEFAULT_SUBJECT = "company" DEFAULT_SUBJECT = "company"
AUTO_TAG = "auto" AUTO_TAG = "auto"
REJECTED_SECRET = "secret-like content rejected" REJECTED_SECRET = "secret-like content rejected"
REJECTED_SENSITIVE_FACT = "sensitive business fact rejected"
AI_MEMORY_CODE_PREFIX = "MEM" AI_MEMORY_CODE_PREFIX = "MEM"

View File

@@ -1,3 +1,4 @@
from datetime import datetime, timedelta
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -99,9 +100,8 @@ class AIMemoryService:
items = items[:limit_value] items = items[:limit_value]
for item in items: for item in items:
item.last_used_at = now item.last_used_at = now
self.db.commit()
result = [serialize_model(item) for item in items] result = [serialize_model(item) for item in items]
self.audit.log( self.audit.record(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.AI_MEMORY, source=AuditSource.AI_MEMORY,
@@ -117,6 +117,7 @@ class AIMemoryService:
response_payload={AIMemoryPayloadKey.COUNT: len(result)}, response_payload={AIMemoryPayloadKey.COUNT: len(result)},
) )
) )
self.db.commit()
return result return result
def auto_write( def auto_write(
@@ -152,8 +153,24 @@ class AIMemoryService:
importance=0, importance=0,
status_value=AIMemoryStatus.REJECTED, status_value=AIMemoryStatus.REJECTED,
actor=actor, actor=actor,
expires_at=utc_now()
+ timedelta(days=settings.ai_memory_auto_write_ttl_days),
) )
return record return record
if _contains_blocked_content(content, settings.ai_memory_blocked_content_terms):
return self._create_entry(
scope=scope,
subject=subject,
content=str(AIMemoryText.REJECTED_SENSITIVE_FACT),
summary=str(AIMemoryText.REJECTED_SENSITIVE_FACT),
tags=[str(AIMemoryText.AUTO_TAG)],
source=AIMemorySource.AUTO,
importance=0,
status_value=AIMemoryStatus.REJECTED,
actor=actor,
expires_at=utc_now()
+ timedelta(days=settings.ai_memory_auto_write_ttl_days),
)
summary = _truncate(answer, AI_MEMORY_MAX_SUMMARY_LENGTH) summary = _truncate(answer, AI_MEMORY_MAX_SUMMARY_LENGTH)
record = self._create_entry( record = self._create_entry(
scope=scope, scope=scope,
@@ -165,6 +182,7 @@ class AIMemoryService:
importance=1, importance=1,
status_value=AIMemoryStatus.ACTIVE, status_value=AIMemoryStatus.ACTIVE,
actor=actor, actor=actor,
expires_at=utc_now() + timedelta(days=settings.ai_memory_auto_write_ttl_days),
) )
return record return record
@@ -275,9 +293,7 @@ class AIMemoryService:
record.tags = ["user-rule", *tags] record.tags = ["user-rule", *tags]
if enabled is not None: if enabled is not None:
record.status = AIMemoryStatus.ACTIVE if enabled else AIMemoryStatus.ARCHIVED record.status = AIMemoryStatus.ACTIVE if enabled else AIMemoryStatus.ARCHIVED
self.db.commit() self.audit.record(
self.db.refresh(record)
self.audit.log(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.AI_MEMORY, source=AuditSource.AI_MEMORY,
@@ -292,6 +308,8 @@ class AIMemoryService:
}, },
) )
) )
self.db.commit()
self.db.refresh(record)
return serialize_model(record) return serialize_model(record)
def _validate_rule(self, content: str, priority: int) -> None: def _validate_rule(self, content: str, priority: int) -> None:
@@ -324,6 +342,7 @@ class AIMemoryService:
status_value: str, status_value: str,
actor: str, actor: str,
audit_action: str = AuditAction.AI_MEMORY_WRITE, audit_action: str = AuditAction.AI_MEMORY_WRITE,
expires_at: datetime | None = None,
) -> AIMemoryEntry: ) -> AIMemoryEntry:
record = AIMemoryEntry( record = AIMemoryEntry(
code=( code=(
@@ -339,11 +358,11 @@ class AIMemoryService:
importance=importance, importance=importance,
status=status_value, status=status_value,
actor=actor, actor=actor,
expires_at=expires_at,
) )
self.db.add(record) self.db.add(record)
self.db.commit() self.db.flush()
self.db.refresh(record) self.audit.record(
self.audit.log(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.AI_MEMORY, source=AuditSource.AI_MEMORY,
@@ -360,7 +379,7 @@ class AIMemoryService:
response_payload={AIMemoryPayloadKey.CODE: record.code}, response_payload={AIMemoryPayloadKey.CODE: record.code},
) )
) )
EventService(self.db).emit( EventService(self.db).enqueue(
event_type=EventType.AI_MEMORY_WRITTEN, event_type=EventType.AI_MEMORY_WRITTEN,
source=EventSource.AI_MEMORY, source=EventSource.AI_MEMORY,
aggregate_type=EventAggregateType.AI_MEMORY_ENTRY, aggregate_type=EventAggregateType.AI_MEMORY_ENTRY,
@@ -373,8 +392,9 @@ class AIMemoryService:
AIMemoryPayloadKey.STATUS: status_value, AIMemoryPayloadKey.STATUS: status_value,
}, },
idempotency_key=f"ai-memory:{record.code}", idempotency_key=f"ai-memory:{record.code}",
dispatch=True,
) )
self.db.commit()
self.db.refresh(record)
return record return record
@@ -402,6 +422,11 @@ def _contains_forbidden_value(value: Any, forbidden_keys: list[str]) -> bool:
return False return False
def _contains_blocked_content(value: str, blocked_terms: list[str]) -> bool:
lowered = value.lower()
return any(term.lower() in lowered for term in blocked_terms if term.strip())
def _matches_query(entry: AIMemoryEntry, query: str) -> bool: def _matches_query(entry: AIMemoryEntry, query: str) -> bool:
query_text = query.lower().strip() query_text = query.lower().strip()
if not query_text: if not query_text:

View File

@@ -44,7 +44,9 @@ class AuditService:
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db self.db = db
def log(self, payload: AuditLogCreate) -> AuditLog: def record(self, payload: AuditLogCreate) -> AuditLog:
"""Stage an audit record in the caller's transaction."""
record = AuditLog( record = AuditLog(
actor=payload.actor, actor=payload.actor,
source=payload.source, source=payload.source,
@@ -58,6 +60,13 @@ class AuditService:
request_id=payload.request_id or get_request_id(), request_id=payload.request_id or get_request_id(),
) )
self.db.add(record) self.db.add(record)
self.db.flush()
return record
def log(self, payload: AuditLogCreate) -> AuditLog:
"""Persist an audit record as a standalone transaction."""
record = self.record(payload)
self.db.commit() self.db.commit()
self.db.refresh(record) self.db.refresh(record)
return record return record

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import JSON, Date, DateTime, Integer, String, Text from sqlalchemy import JSON, Date, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue from app.core.constants import ActorValue
@@ -42,7 +42,10 @@ class RiskEventAction(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True) code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
risk_event_id: Mapped[int] = mapped_column(Integer, index=True) risk_event_id: Mapped[int] = mapped_column(
ForeignKey("risk_events.id", ondelete="RESTRICT"),
index=True,
)
action: Mapped[str] = mapped_column(String(64), index=True) action: Mapped[str] = mapped_column(String(64), index=True)
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True) from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)

View File

@@ -60,6 +60,7 @@ class EventPayloadKey(StrEnum):
class EventErrorDetail(StrEnum): class EventErrorDetail(StrEnum):
EVENT_NOT_FOUND = "Domain event not found" EVENT_NOT_FOUND = "Domain event not found"
EVENT_NOT_RETRYABLE = "Domain event is not retryable" EVENT_NOT_RETRYABLE = "Domain event is not retryable"
EVENT_LOCKED = "Domain event is already being processed"
EVENT_CODE_PREFIX = "EVT" EVENT_CODE_PREFIX = "EVT"

View File

@@ -36,6 +36,6 @@ class DomainEvent(Base):
) )
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
max_attempts: Mapped[int] = mapped_column(Integer, default=3) max_attempts: Mapped[int] = mapped_column(Integer, default=3, server_default="3")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
processed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) processed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)

View File

@@ -1,15 +1,17 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.application.events import EventDispatchService
from app.core.database import get_db from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.events.constants import EventResponseKey from app.modules.events.constants import EventResponseKey
from app.modules.events.schemas import DomainEventListRead, DomainEventRead
from app.modules.events.services import EventService, _serialize_event from app.modules.events.services import EventService, _serialize_event
router = APIRouter(dependencies=[Depends(require_api_key)]) router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("") @router.get("", response_model=DomainEventListRead)
def list_events( def list_events(
status: str | None = None, status: str | None = None,
event_type: str | None = None, event_type: str | None = None,
@@ -25,15 +27,19 @@ def list_events(
} }
@router.post("/{event_id}/dispatch") @router.post("/{event_id}/dispatch", response_model=dict[str, DomainEventRead])
def dispatch_event( def dispatch_event(
event_id: str, event_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
return {EventResponseKey.EVENT: _serialize_event(EventService(db).dispatch_event(event_id))} return {
EventResponseKey.EVENT: _serialize_event(
EventDispatchService(db).dispatch_event(event_id)
)
}
@router.post("/{event_id}/retry") @router.post("/{event_id}/retry", response_model=dict[str, DomainEventRead])
def retry_event( def retry_event(
event_id: str, event_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -41,14 +47,16 @@ def retry_event(
) -> dict: ) -> dict:
return { return {
EventResponseKey.EVENT: _serialize_event( EventResponseKey.EVENT: _serialize_event(
EventService(db).retry_event(event_id, actor=principal.actor) EventDispatchService(db).retry_event(event_id, actor=principal.actor)
) )
} }
@router.post("/dispatch-pending") @router.post("/dispatch-pending", response_model=DomainEventListRead)
def dispatch_pending( def dispatch_pending(
limit: int = Query(default=100, ge=1, le=500), limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
return {EventResponseKey.ITEMS: EventService(db).dispatch_pending(limit=limit)} return {
EventResponseKey.ITEMS: EventDispatchService(db).dispatch_pending(limit=limit)
}

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@@ -15,9 +16,9 @@ class DomainEventRead(BaseModel):
attempts: int attempts: int
idempotency_key: str | None idempotency_key: str | None
last_error: str | None last_error: str | None
created_at: str created_at: datetime
processed_at: str | None processed_at: datetime | None
class DomainEventListRead(BaseModel): class DomainEventListRead(BaseModel):
items: list[dict[str, Any]] items: list[DomainEventRead]

View File

@@ -10,14 +10,13 @@ from app.core.utils.time import utc_now
from app.modules.events.constants import ( from app.modules.events.constants import (
EVENT_CODE_PREFIX, EVENT_CODE_PREFIX,
EventErrorDetail, EventErrorDetail,
EventStatus,
) )
from app.modules.events.models import DomainEvent from app.modules.events.models import DomainEvent
from app.modules.events.services.serialization import _serialize_event from app.modules.events.services.serialization import _serialize_event
class EventQueryMixin: class EventQueryMixin:
def emit( def enqueue(
self, self,
event_type: str, event_type: str,
source: str, source: str,
@@ -26,16 +25,12 @@ class EventQueryMixin:
actor: str = ActorValue.SYSTEM, actor: str = ActorValue.SYSTEM,
payload: dict[str, Any] | None = None, payload: dict[str, Any] | None = None,
idempotency_key: str | None = None, idempotency_key: str | None = None,
dispatch: bool = False,
) -> DomainEvent: ) -> DomainEvent:
if idempotency_key: """Stage an outbox event in the caller's transaction."""
existing = self.db.execute(
select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key) existing = self._find_idempotent_event(idempotency_key)
).scalar_one_or_none() if existing is not None:
if existing is not None: return existing
if dispatch and existing.status == EventStatus.PENDING:
return self.dispatch_event(existing.event_id)
return existing
settings = get_settings() settings = get_settings()
now = utc_now() now = utc_now()
@@ -52,12 +47,43 @@ class EventQueryMixin:
max_attempts=settings.event_dispatch_max_attempts, max_attempts=settings.event_dispatch_max_attempts,
) )
self.db.add(record) self.db.add(record)
self.db.flush()
return record
def emit(
self,
event_type: str,
source: str,
aggregate_type: str,
aggregate_id: str | int | None,
actor: str = ActorValue.SYSTEM,
payload: dict[str, Any] | None = None,
idempotency_key: str | None = None,
) -> DomainEvent:
existing = self._find_idempotent_event(idempotency_key)
if existing is not None:
return existing
record = self.enqueue(
event_type=event_type,
source=source,
aggregate_type=aggregate_type,
aggregate_id=aggregate_id,
actor=actor,
payload=payload,
idempotency_key=idempotency_key,
)
self.db.commit() self.db.commit()
self.db.refresh(record) self.db.refresh(record)
if dispatch:
return self.dispatch_event(record.event_id)
return record return record
def _find_idempotent_event(self, idempotency_key: str | None) -> DomainEvent | None:
if not idempotency_key:
return None
return self.db.execute(
select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key)
).scalar_one_or_none()
def list_events( def list_events(
self, self,
status_filter: str | None = None, status_filter: str | None = None,

View File

@@ -1,16 +1,12 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.modules.events.services.dispatch import EventDispatchMixin
from app.modules.events.services.handlers import EventHandlerMixin
from app.modules.events.services.query import EventQueryMixin from app.modules.events.services.query import EventQueryMixin
class EventService( class EventService(
EventDispatchMixin,
EventHandlerMixin,
EventQueryMixin, EventQueryMixin,
): ):
"""Persist outbox events and dispatch the V3 internal handlers.""" """Persist and query transactional outbox events."""
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db self.db = db

View File

@@ -1,732 +0,0 @@
import json
import re
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.config import get_settings
from app.modules.ai_agent.service import AIService
from app.modules.ai_agent.constants import AIResponseKey
from app.modules.audit.constants import AuditSource
from app.modules.ai_memory.constants import AIMemoryStatus
from app.modules.ai_memory.service import AIMemoryService
from app.modules.feishu.constants import (
FEISHU_AI_REPLY_TITLE,
FEISHU_MENTION_PATTERN,
FEISHU_ZERO_WIDTH_SPACE,
FeishuCommandKey,
FeishuCommandName,
FeishuCommandResultKey,
FeishuPayloadKey,
FeishuReplyType,
)
from app.modules.feishu.service import FeishuService
from app.modules.market.chart import render_market_chart
from app.modules.market.service import MarketService
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
from app.modules.reports.constants import ReportResponseKey
from app.modules.reports.services import ReportService
DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报")
PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报")
ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance")
RISK_KEYWORDS = ("风险", "预警", "risk")
AI_COMMAND_PREFIXES = ("", "ai ", "AI ", "/ask ")
DEFAULT_AI_PROMPT = "请说明你能做什么。"
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_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_LIST_COMMANDS = {"查看规则", "规则列表", "查看市场规则"}
RULE_COMMAND_PREFIXES = (
"学习市场规则",
"学习规则",
"查看市场规则",
"查看规则",
"规则列表",
"停用规则",
"启用规则",
)
RULE_COMMAND_HELP = (
"规则指令格式:\n"
"学习规则:<规则内容>\n"
"学习规则 80<规则内容>\n"
"学习市场规则 80<仅用于市场分析的规则内容>\n"
"查看规则\n"
"停用规则 <规则编号>\n"
"启用规则 <规则编号>"
)
PROJECT_FINANCE_PATTERN = re.compile(r"^项目资金\s+(.+)$")
FINANCE_COMMANDS = {"资金需求", "未来30天资金需求"}
STOCK_ANALYSIS_PATTERN = re.compile(
r"^(?:股票分析|估值分析|财报分析)\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$", re.IGNORECASE
)
WATCHLIST_ADD_PATTERN = re.compile(r"^加入自选\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$", re.IGNORECASE)
MARKET_COMMANDS = {
"市场分析",
"今日收盘分析",
"本周市场分析",
"宏观金融分析",
"最新公告",
}
INDUSTRY_ANALYSIS_PATTERN = re.compile(r"^行业分析\s+(.+)$")
STOCK_COMPARE_PATTERN = re.compile(
r"^股票对比\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)\s+" r"([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$",
re.IGNORECASE,
)
def _parse_content_text(content: Any) -> str:
"""Extract plain command text from a Feishu message content payload."""
if isinstance(content, dict):
return str(
content.get(FeishuPayloadKey.TEXT) or content.get(FeishuPayloadKey.CONTENT) or ""
)
if not isinstance(content, str):
return ""
try:
data = json.loads(content)
except json.JSONDecodeError:
return content
if isinstance(data, dict):
return str(data.get(FeishuPayloadKey.TEXT) or data.get(FeishuPayloadKey.CONTENT) or "")
return content
def _clean_command_text(text: str) -> str:
"""Remove mentions and invisible characters from Feishu command text."""
text = re.sub(FEISHU_MENTION_PATTERN, "", text or "")
text = text.replace(FEISHU_ZERO_WIDTH_SPACE, "")
return text.strip()
def _command_result(
command: FeishuCommandName,
reply_type: FeishuReplyType,
title: str,
content: str,
provider_response: dict[str, Any] | None = None,
lines: list[str] | None = None,
) -> dict[str, Any]:
result: dict[str, Any] = {
FeishuCommandResultKey.COMMAND: command,
FeishuCommandResultKey.REPLY_TYPE: reply_type,
FeishuCommandResultKey.TITLE: title,
FeishuCommandResultKey.CONTENT: content,
FeishuCommandResultKey.PROVIDER_RESPONSE: provider_response,
}
if lines is not None:
result[FeishuCommandResultKey.LINES] = lines
return result
class FeishuCommandService:
"""Route Feishu text commands to reports, risk summaries, or AI replies."""
def __init__(self, db: Session):
self.db = db
self.feishu = FeishuService(db)
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
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:
return None
sender = event.get(FeishuPayloadKey.SENDER) or {}
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
actor = (
sender_id.get(FeishuPayloadKey.OPEN_ID)
or sender_id.get(FeishuPayloadKey.USER_ID)
or ActorValue.FEISHU
)
return {
FeishuCommandKey.TEXT: text,
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
FeishuCommandKey.ACTOR: actor,
}
def handle_text(
self,
text: str,
chat_id: str | None = None,
actor: str = ActorValue.FEISHU,
auto_reply: bool = True,
) -> dict[str, Any]:
command_text = _clean_command_text(text)
lowered = command_text.lower()
provider_response: dict[str, Any] | None = None
rule_result = self._handle_rule_command(
command_text,
chat_id=chat_id,
actor=actor,
auto_reply=auto_reply,
)
if rule_result is not None:
return rule_result
finance_result = self._handle_finance_command(
command_text,
chat_id=chat_id,
actor=actor,
auto_reply=auto_reply,
)
if finance_result is not None:
return finance_result
market_result = self._handle_market_command(command_text, chat_id, actor, auto_reply)
if market_result is not None:
return market_result
if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
report = ReportService(self.db).daily_brief()
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
actor,
)
return _command_result(
FeishuCommandName.DAILY_BRIEF,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
if any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS):
report = ReportService(self.db).project_weekly()
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
actor,
)
return _command_result(
FeishuCommandName.PROJECT_WEEKLY,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
if any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS):
report = ReportService(self.db).attendance_summary()
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
actor,
)
return _command_result(
FeishuCommandName.ATTENDANCE_SUMMARY,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
if any(keyword in command_text for keyword in RISK_KEYWORDS):
report = ReportService(self.db).risk_progress()
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
actor,
)
return _command_result(
FeishuCommandName.RISK_SUMMARY,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
prompt = command_text
for prefix in AI_COMMAND_PREFIXES:
if command_text.startswith(prefix):
prompt = command_text[len(prefix) :].strip()
break
if not prompt:
prompt = DEFAULT_AI_PROMPT
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)
for prefix in AI_COMMAND_PREFIXES
)
if auto_reply:
provider_response = self._send_text_if_configured(chat_id, content, actor)
return _command_result(
FeishuCommandName.AI_ASK if is_explicit_ai else FeishuCommandName.FALLBACK_AI,
FeishuReplyType.TEXT,
FEISHU_AI_REPLY_TITLE,
content,
provider_response,
)
def _handle_finance_command(
self,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
project_match = PROJECT_FINANCE_PATTERN.fullmatch(command_text)
if command_text not in FINANCE_COMMANDS and project_match is None:
return None
command = (
FeishuCommandName.PROJECT_FINANCE if project_match else FeishuCommandName.FINANCE_NEEDS
)
if not get_settings().finance_needs_enabled:
content = "项目资金需求分析尚未启用,请先配置并启用财务只读同步。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
project_code = project_match.group(1).strip() if project_match else None
service = ReportService(self.db)
preview = service.project_finance_needs_report(
project_code=project_code,
include_ai=False,
actor=actor,
)
if project_code and not preview["items"]:
content = f"未找到项目“{project_code}”,请使用稳定项目编号或展示编号。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
if not preview["summary"]["data_available"]:
content = "项目财务数据未接入或无有效记录,暂不生成资金分析报告。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
report = service.project_finance_needs_report(
project_code=project_code,
include_ai=True,
actor=actor,
)
ai_analysis = report.get("ai_analysis") or {}
if not ai_analysis.get(AIResponseKey.OK):
content = "AI 当前不可用,本次项目资金分析报告未发送。请检查模型服务。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"AI 暂不可用",
content,
provider_response,
)
provider_response = None
if auto_reply:
provider_response = self._send_finance_card_if_configured(chat_id, report, actor)
return _command_result(
command,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
def _handle_market_command(
self, text: str, chat_id: str | None, actor: str, auto_reply: bool
) -> dict[str, Any] | None:
stock = STOCK_ANALYSIS_PATTERN.fullmatch(text)
add = WATCHLIST_ADD_PATTERN.fullmatch(text)
industry = INDUSTRY_ANALYSIS_PATTERN.fullmatch(text)
comparison = STOCK_COMPARE_PATTERN.fullmatch(text)
if (
text not in MARKET_COMMANDS
and text != "查看自选"
and not stock
and not add
and not industry
and not comparison
):
return None
if not get_settings().market_analysis_enabled:
content = "市场分析尚未启用,请配置市场数据源后启用。"
response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
FeishuCommandName.MARKET_OVERVIEW,
FeishuReplyType.TEXT,
"市场分析",
content,
response,
)
service = MarketService(self.db)
if add:
if get_settings().read_only_mode:
content = "当前为只读模式,不能修改自选股。请由管理员启用操作后重试。"
response = (
self._send_text_if_configured(chat_id, content, actor)
if auto_reply
else None
)
return _command_result(
FeishuCommandName.WATCHLIST_ADD,
FeishuReplyType.TEXT,
"自选股",
content,
response,
)
item = service.add_watchlist(actor, add.group(1))
content = f"已加入自选:{item['symbol']}"
response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
FeishuCommandName.WATCHLIST_ADD, FeishuReplyType.TEXT, "自选股", content, response
)
if text == "查看自选":
items = service.watchlist(actor)
content = "自选股:" + ("".join(item["symbol"] for item in items) or "暂无")
response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
FeishuCommandName.WATCHLIST_LIST, FeishuReplyType.TEXT, "自选股", content, response
)
if text == "最新公告":
items = service.announcements(limit=10)["items"]
content = (
"最新公告:\n"
+ "\n".join(
f"- {item['announcement_date']} {item['symbol'] or '市场'}{item['title']}"
for item in items
)
if items
else "公告元数据尚未接入。"
)
response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
FeishuCommandName.MARKET_ANNOUNCEMENTS,
FeishuReplyType.TEXT,
"最新公告",
content,
response,
)
if industry:
try:
data = service.industry_analysis(industry.group(1).strip())
content = (
f"{data['industry']} 平均涨跌 {data['average_pct_change']}%\n"
+ "\n".join(
f"- {item['name']}{item['symbol']}{item['pct_change']}%"
for item in data["items"][:10]
)
)
except HTTPException:
content = "未找到该行业的最新市场数据。"
response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
FeishuCommandName.MARKET_OVERVIEW,
FeishuReplyType.TEXT,
"行业分析",
content,
response,
)
if comparison:
try:
content = service.compare_stocks([comparison.group(1), comparison.group(2)])[
"content"
]
except HTTPException:
content = "至少一只股票缺少可用行情,暂时无法比较。"
response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
FeishuCommandName.STOCK_ANALYSIS,
FeishuReplyType.TEXT,
"股票对比",
content,
response,
)
command = FeishuCommandName.STOCK_ANALYSIS if stock else FeishuCommandName.MARKET_OVERVIEW
if text == "宏观金融分析":
command = FeishuCommandName.MARKET_MACRO
try:
if stock:
report = service.stock_analysis(stock.group(1), True, actor)
elif text == "本周市场分析":
report = service.weekly_overview(include_ai=True, actor=actor)
elif text == "宏观金融分析":
report = service.macro_analysis(include_ai=True, actor=actor)
else:
report = service.market_overview(include_ai=True, actor=actor)
except HTTPException:
content = "未找到该股票的可用行情,请确认代码或先执行行情同步。"
response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(command, FeishuReplyType.TEXT, "股票分析", content, response)
ai = report.get("ai_analysis") or {}
if not report.get("data_available"):
content = "市场数据未接入,暂不生成分析报告。"
elif not ai.get("ok"):
content = "AI 当前不可用,本次市场分析报告未发送。"
else:
content = report["content"]
response = None
if auto_reply:
if ai.get("ok"):
if text == "宏观金融分析":
response = self._send_text_if_configured(chat_id, content, actor)
else:
image = self.feishu.upload_image(render_market_chart(report), actor)
image_key = (image.get("data") or {}).get("image_key")
if not image_key:
raise ValueError("Feishu image upload did not return image_key")
card = FeishuService.build_basic_card(
report["title"],
report["lines"],
image_key=image_key,
image_alt=report["title"],
)
response = self.feishu.send_card(card, receive_id=chat_id, actor=actor)
else:
response = self._send_text_if_configured(chat_id, content, actor)
return _command_result(
command,
(
FeishuReplyType.CARD
if ai.get("ok") and text != "宏观金融分析"
else FeishuReplyType.TEXT
),
report["title"],
content,
response,
report["lines"] if ai.get("ok") else None,
)
def _send_finance_card_if_configured(
self,
chat_id: str | None,
report: dict[str, Any],
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
chart_data = {
"period": report.get("as_of"),
"finance": report.get("finance_chart_data"),
}
image_result = self.feishu.upload_image(render_lifecycle_chart(chart_data), actor)
image_key = (image_result.get("data") or {}).get("image_key")
if not image_key:
raise ValueError("Feishu image upload did not return image_key")
card = FeishuService.build_basic_card(
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
image_key=image_key,
image_alt=lifecycle_chart_alt(chart_data),
)
return self.feishu.send_card(card, receive_id=chat_id, actor=actor)
def _handle_rule_command(
self,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
if not command_text.startswith(RULE_COMMAND_PREFIXES):
return None
if command_text.startswith("停用规则"):
command = FeishuCommandName.RULE_DISABLE
elif command_text.startswith("启用规则"):
command = FeishuCommandName.RULE_ENABLE
elif command_text.startswith(("查看市场规则", "查看规则", "规则列表")):
command = FeishuCommandName.RULE_LIST
else:
command = FeishuCommandName.RULE_CREATE
if command in {
FeishuCommandName.RULE_CREATE,
FeishuCommandName.RULE_DISABLE,
FeishuCommandName.RULE_ENABLE,
} and get_settings().read_only_mode:
content = "当前为只读模式,不能新增或修改学习规则。请由管理员启用操作后重试。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
RULE_TITLE,
content,
provider_response,
)
content = RULE_COMMAND_HELP
try:
market_create_match = MARKET_RULE_CREATE_PATTERN.fullmatch(command_text)
create_match = market_create_match or RULE_CREATE_PATTERN.fullmatch(command_text)
disable_match = RULE_DISABLE_PATTERN.fullmatch(command_text)
enable_match = RULE_ENABLE_PATTERN.fullmatch(command_text)
memory = AIMemoryService(self.db)
if create_match:
priority = int(create_match.group(1) or 50)
rule_content = create_match.group(2).strip()
if not rule_content:
content = f"规则内容不能为空。\n\n{RULE_COMMAND_HELP}"
elif not 1 <= priority <= 100:
content = "规则优先级必须在 1 到 100 之间。"
else:
rule = memory.create_rule(
content=rule_content,
scope="market" if market_create_match else "global",
subject="market" if market_create_match else "company",
priority=priority,
tags=["feishu", *(["market"] if market_create_match else [])],
actor=actor,
)
content = (
"规则已学习。\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
"状态:已启用"
)
elif command_text in RULE_LIST_COMMANDS:
command = FeishuCommandName.RULE_LIST
rules = memory.list_rules(
scope="market" if command_text == "查看市场规则" else None,
status_filter=AIMemoryStatus.ACTIVE,
limit=20,
)
if not rules:
content = "当前没有已启用的学习规则。"
else:
lines = ["当前已启用的学习规则:"]
for rule in rules:
rule_text = str(rule["content"])
if len(rule_text) > 80:
rule_text = f"{rule_text[:80]}"
lines.append(
f"{rule['code']}|优先级 {rule['importance']}"
f"{rule['scope']}/{rule['subject']}\n{rule_text}"
)
content = "\n\n".join(lines)
elif disable_match or enable_match:
enabled = enable_match is not None
command = (
FeishuCommandName.RULE_ENABLE if enabled else FeishuCommandName.RULE_DISABLE
)
match = enable_match or disable_match
rule = memory.update_rule(
code=match.group(1),
content=None,
priority=None,
tags=None,
enabled=enabled,
actor=actor,
)
state = "已启用" if enabled else "已停用"
content = (
f"规则{state}\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
f"状态:{state}"
)
except HTTPException as exc:
detail = str(exc.detail)
if "secret-like" in detail:
content = "规则疑似包含密码、令牌或其他密钥信息,已拒绝学习。"
elif exc.status_code == 404:
content = "没有找到该规则,请先发送“查看规则”确认规则编号。"
elif "priority" in detail:
content = "规则优先级必须在 1 到 100 之间。"
else:
content = "规则未保存,请检查指令内容后重试。"
provider_response = None
if auto_reply:
provider_response = self._send_text_if_configured(chat_id, content, actor)
return _command_result(
command,
FeishuReplyType.TEXT,
RULE_TITLE,
content,
provider_response,
)
def _send_card_if_configured(
self,
chat_id: str | None,
title: str,
lines: list[str],
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
card = FeishuService.build_basic_card(title, lines)
return self.feishu.send_card(card, receive_id=chat_id, actor=actor)
def _send_text_if_configured(
self,
chat_id: str | None,
text: str,
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
return self.feishu.send_text(text, receive_id=chat_id, actor=actor)

View File

@@ -6,7 +6,7 @@ from urllib.parse import urlsplit
from app.core.config import get_settings from app.core.config import get_settings
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.modules.feishu.constants import FEISHU_DEFAULT_OPEN_API_DOMAIN, FeishuEventSource from app.modules.feishu.constants import FEISHU_DEFAULT_OPEN_API_DOMAIN, FeishuEventSource
from app.modules.feishu.events import FeishuEventService from app.application.feishu import FeishuEventService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, Request from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.feishu.commands import FeishuCommandService from app.application.feishu import FeishuCommandService, FeishuEventService
from app.modules.feishu.constants import FeishuEventSource, FeishuPayloadKey, FeishuResponseKey from app.modules.feishu.constants import FeishuEventSource, FeishuPayloadKey, FeishuResponseKey
from app.modules.feishu.events import FeishuEventService
from app.modules.feishu.schemas import ( from app.modules.feishu.schemas import (
FeishuCardMessage, FeishuCardMessage,
FeishuCommandRequest, FeishuCommandRequest,
@@ -19,10 +20,9 @@ router = APIRouter()
@router.post("/webhook") @router.post("/webhook")
async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dict: def feishu_webhook(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict:
"""Handle Feishu webhook challenge and text command events.""" """Handle Feishu webhook challenge and text command events."""
payload = await request.json()
return FeishuEventService(db).handle_event( return FeishuEventService(db).handle_event(
payload, payload,
source=FeishuEventSource.WEBHOOK, source=FeishuEventSource.WEBHOOK,

View File

@@ -4,6 +4,7 @@ from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.security import ensure_business_mutations_enabled
from app.core.utils.time import utc_now from app.core.utils.time import utc_now
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
@@ -43,6 +44,7 @@ class LegacyProjectSyncMixin:
dry_run: bool = True, dry_run: bool = True,
actor: str = ActorValue.API, actor: str = ActorValue.API,
) -> dict[str, Any]: ) -> dict[str, Any]:
ensure_business_mutations_enabled()
if self.db is None: if self.db is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -120,9 +122,6 @@ class LegacyProjectSyncMixin:
} }
) )
if not dry_run:
self.db.commit()
result = { result = {
LegacyResponseKey.DRY_RUN: dry_run, LegacyResponseKey.DRY_RUN: dry_run,
LegacyResponseKey.CREATED: created, LegacyResponseKey.CREATED: created,
@@ -142,11 +141,10 @@ class LegacyProjectSyncMixin:
note=LEGACY_PROJECT_SYNC_NOTE, note=LEGACY_PROJECT_SYNC_NOTE,
) )
self.db.add(sync_run) self.db.add(sync_run)
self.db.commit() self.db.flush()
self.db.refresh(sync_run)
result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code
AuditService(self.db).log( AuditService(self.db).record(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.LEGACY_MYSQL, source=AuditSource.LEGACY_MYSQL,
@@ -170,7 +168,7 @@ class LegacyProjectSyncMixin:
}, },
) )
) )
EventService(self.db).emit( EventService(self.db).enqueue(
event_type=EventType.LEGACY_SYNC_COMPLETED, event_type=EventType.LEGACY_SYNC_COMPLETED,
source=EventSource.LEGACY_MYSQL, source=EventSource.LEGACY_MYSQL,
aggregate_type=EventAggregateType.LEGACY_SYNC_RUN, aggregate_type=EventAggregateType.LEGACY_SYNC_RUN,
@@ -186,4 +184,6 @@ class LegacyProjectSyncMixin:
}, },
idempotency_key=f"legacy-sync:{sync_run.code}", idempotency_key=f"legacy-sync:{sync_run.code}",
) )
self.db.commit()
self.db.refresh(sync_run)
return result return result

View File

@@ -4,6 +4,7 @@ from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.security import ensure_business_mutations_enabled
from app.core.utils.time import utc_now from app.core.utils.time import utc_now
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
@@ -43,6 +44,7 @@ class LegacyTaskSyncMixin:
dry_run: bool = True, dry_run: bool = True,
actor: str = ActorValue.API, actor: str = ActorValue.API,
) -> dict[str, Any]: ) -> dict[str, Any]:
ensure_business_mutations_enabled()
if self.db is None: if self.db is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -120,9 +122,6 @@ class LegacyTaskSyncMixin:
} }
) )
if not dry_run:
self.db.commit()
result = { result = {
LegacyResponseKey.DRY_RUN: dry_run, LegacyResponseKey.DRY_RUN: dry_run,
LegacyResponseKey.CREATED: created, LegacyResponseKey.CREATED: created,
@@ -142,11 +141,10 @@ class LegacyTaskSyncMixin:
note=LEGACY_TASK_SYNC_NOTE, note=LEGACY_TASK_SYNC_NOTE,
) )
self.db.add(sync_run) self.db.add(sync_run)
self.db.commit() self.db.flush()
self.db.refresh(sync_run)
result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code
AuditService(self.db).log( AuditService(self.db).record(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.LEGACY_MYSQL, source=AuditSource.LEGACY_MYSQL,
@@ -170,7 +168,7 @@ class LegacyTaskSyncMixin:
}, },
) )
) )
EventService(self.db).emit( EventService(self.db).enqueue(
event_type=EventType.LEGACY_SYNC_COMPLETED, event_type=EventType.LEGACY_SYNC_COMPLETED,
source=EventSource.LEGACY_MYSQL, source=EventSource.LEGACY_MYSQL,
aggregate_type=EventAggregateType.LEGACY_SYNC_RUN, aggregate_type=EventAggregateType.LEGACY_SYNC_RUN,
@@ -186,4 +184,6 @@ class LegacyTaskSyncMixin:
}, },
idempotency_key=f"legacy-sync:{sync_run.code}", idempotency_key=f"legacy-sync:{sync_run.code}",
) )
self.db.commit()
self.db.refresh(sync_run)
return result return result

View File

@@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import require_api_key
from app.modules.observability.constants import ObservabilityKey, ObservabilityStatus
from app.modules.observability.service import ObservabilityService from app.modules.observability.service import ObservabilityService
router = APIRouter() router = APIRouter()
@@ -18,9 +19,13 @@ def live(
@router.get("/health/ready") @router.get("/health/ready")
def ready( def ready(
response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
return ObservabilityService(db).ready() result = ObservabilityService(db).ready()
if result[ObservabilityKey.STATUS] != ObservabilityStatus.OK:
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return result
@router.get("/metrics", dependencies=[Depends(require_api_key)]) @router.get("/metrics", dependencies=[Depends(require_api_key)])

View File

@@ -94,9 +94,7 @@ class ObservabilityService:
record.status = status_value record.status = status_value
record.last_seen_at = now record.last_seen_at = now
record.updated_at = now record.updated_at = now
self.db.commit() AuditService(self.db).record(
self.db.refresh(record)
AuditService(self.db).log(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.OBSERVABILITY, source=AuditSource.OBSERVABILITY,
@@ -112,6 +110,8 @@ class ObservabilityService:
}, },
) )
) )
self.db.commit()
self.db.refresh(record)
return self._serialize_heartbeat(record) return self._serialize_heartbeat(record)
def heartbeat_summary(self) -> dict[str, Any]: def heartbeat_summary(self) -> dict[str, Any]:

View File

@@ -3,6 +3,7 @@ from datetime import date
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.application.delivery import ReportDeliveryService
from app.core.background.task_queue import ( from app.core.background.task_queue import (
enqueue_attendance_summary_push, enqueue_attendance_summary_push,
enqueue_daily_brief_push, enqueue_daily_brief_push,
@@ -188,7 +189,7 @@ def push_daily_brief(
) -> dict: ) -> dict:
service = ReportService(db) service = ReportService(db)
report = service.daily_brief() report = service.daily_brief()
return service.push_report( return ReportDeliveryService(db).push_report(
report, report,
payload.receive_id, payload.receive_id,
payload.receive_id_type, payload.receive_id_type,
@@ -204,7 +205,7 @@ def push_project_weekly(
) -> dict: ) -> dict:
service = ReportService(db) service = ReportService(db)
report = service.project_weekly() report = service.project_weekly()
return service.push_report( return ReportDeliveryService(db).push_report(
report, report,
payload.receive_id, payload.receive_id,
payload.receive_id_type, payload.receive_id_type,
@@ -220,7 +221,7 @@ def push_attendance_summary(
) -> dict: ) -> dict:
service = ReportService(db) service = ReportService(db)
report = service.attendance_summary() report = service.attendance_summary()
return service.push_report( return ReportDeliveryService(db).push_report(
report, report,
payload.receive_id, payload.receive_id,
payload.receive_id_type, payload.receive_id_type,
@@ -236,7 +237,7 @@ def push_risk_progress(
) -> dict: ) -> dict:
service = ReportService(db) service = ReportService(db)
report = service.risk_progress() report = service.risk_progress()
return service.push_report( return ReportDeliveryService(db).push_report(
report, report,
payload.receive_id, payload.receive_id,
payload.receive_id_type, payload.receive_id_type,
@@ -252,7 +253,7 @@ def push_work_daily(
) -> dict: ) -> dict:
service = ReportService(db) service = ReportService(db)
report = service.work_daily_report(reporter=principal.actor, actor=principal.actor) report = service.work_daily_report(reporter=principal.actor, actor=principal.actor)
return service.push_report( return ReportDeliveryService(db).push_report(
report, report,
payload.receive_id, payload.receive_id,
payload.receive_id_type, payload.receive_id_type,
@@ -268,7 +269,7 @@ def push_work_weekly(
) -> dict: ) -> dict:
service = ReportService(db) service = ReportService(db)
report = service.work_weekly_report(reporter=principal.actor, actor=principal.actor) report = service.work_weekly_report(reporter=principal.actor, actor=principal.actor)
return service.push_report( return ReportDeliveryService(db).push_report(
report, report,
payload.receive_id, payload.receive_id,
payload.receive_id_type, payload.receive_id_type,

View File

@@ -111,7 +111,7 @@ class ReportEnterpriseAnalyticsMixin:
EnterpriseAnalyticsKey.CONTENT: "\n".join(lines), EnterpriseAnalyticsKey.CONTENT: "\n".join(lines),
} }
) )
AuditService(self.db).log( AuditService(self.db).record(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.REPORTS, source=AuditSource.REPORTS,
@@ -121,7 +121,7 @@ class ReportEnterpriseAnalyticsMixin:
response_payload=report, response_payload=report,
) )
) )
EventService(self.db).emit( EventService(self.db).enqueue(
event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED, event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED,
source=EventSource.ANALYTICS, source=EventSource.ANALYTICS,
aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS, aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS,
@@ -132,8 +132,8 @@ class ReportEnterpriseAnalyticsMixin:
EventPayloadKey.STATUS: ReportStatus.GENERATED, EventPayloadKey.STATUS: ReportStatus.GENERATED,
}, },
idempotency_key=f"enterprise-analytics:{code}", idempotency_key=f"enterprise-analytics:{code}",
dispatch=True,
) )
self.db.commit()
return report return report
def _enterprise_performance_stats(self) -> dict[str, Any]: def _enterprise_performance_stats(self) -> dict[str, Any]:

View File

@@ -60,6 +60,7 @@ class ReportPushRunMixin:
provider_response: dict[str, Any] | None = None, provider_response: dict[str, Any] | None = None,
error_message: str | None = None, error_message: str | None = None,
sent: bool = False, sent: bool = False,
commit: bool = True,
) -> ReportPushRun: ) -> ReportPushRun:
record = self._get_push_run(code) record = self._get_push_run(code)
record.status = status record.status = status
@@ -70,8 +71,11 @@ class ReportPushRunMixin:
record.error_message = error_message record.error_message = error_message
if sent: if sent:
record.sent_at = utc_now() record.sent_at = utc_now()
self.db.commit() if commit:
self.db.refresh(record) self.db.commit()
self.db.refresh(record)
else:
self.db.flush()
return record return record
def list_push_runs( def list_push_runs(

View File

@@ -1,7 +1,6 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.modules.reports.services.common import ReportQueryMixin from app.modules.reports.services.common import ReportQueryMixin
from app.modules.reports.services.delivery import ReportDeliveryMixin
from app.modules.reports.services.enterprise import ReportEnterpriseAnalyticsMixin from app.modules.reports.services.enterprise import ReportEnterpriseAnalyticsMixin
from app.modules.reports.services.finance_needs import FinanceNeedsReportMixin from app.modules.reports.services.finance_needs import FinanceNeedsReportMixin
from app.modules.reports.services.lifecycle import ReportLifecycleMixin from app.modules.reports.services.lifecycle import ReportLifecycleMixin
@@ -13,7 +12,6 @@ from app.modules.risk.services import RiskService
class ReportService( class ReportService(
ReportDeliveryMixin,
FinanceNeedsReportMixin, FinanceNeedsReportMixin,
IntasectLifecycleReportMixin, IntasectLifecycleReportMixin,
ReportWorkReportMixin, ReportWorkReportMixin,

View File

@@ -119,10 +119,9 @@ class ReportWorkReportMixin:
risk_summary=risk_summary, risk_summary=risk_summary,
) )
self.db.add(record) self.db.add(record)
self.db.commit() self.db.flush()
self.db.refresh(record)
record_data = serialize_model(record) record_data = serialize_model(record)
AuditService(self.db).log( AuditService(self.db).record(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.REPORTS, source=AuditSource.REPORTS,
@@ -132,7 +131,7 @@ class ReportWorkReportMixin:
response_payload=record_data, response_payload=record_data,
) )
) )
EventService(self.db).emit( EventService(self.db).enqueue(
event_type=EventType.REPORT_GENERATED, event_type=EventType.REPORT_GENERATED,
source=EventSource.REPORTS, source=EventSource.REPORTS,
aggregate_type=EventAggregateType.WORK_REPORT, aggregate_type=EventAggregateType.WORK_REPORT,
@@ -143,8 +142,9 @@ class ReportWorkReportMixin:
EventPayloadKey.STATUS: record.status, EventPayloadKey.STATUS: record.status,
}, },
idempotency_key=f"report-generated:{record.code}", idempotency_key=f"report-generated:{record.code}",
dispatch=True,
) )
self.db.commit()
self.db.refresh(record)
return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data} return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data}

View File

@@ -2,6 +2,7 @@ from typing import Any
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.security import ensure_business_mutations_enabled
from app.core.utils.time import utc_now from app.core.utils.time import utc_now
from app.modules.audit.constants import ( from app.modules.audit.constants import (
AuditAction, AuditAction,
@@ -181,6 +182,7 @@ class RiskActionMixin:
comment: str | None, comment: str | None,
payload: dict[str, Any], payload: dict[str, Any],
) -> RiskEventAction: ) -> RiskEventAction:
ensure_business_mutations_enabled()
action_record = RiskEventAction( action_record = RiskEventAction(
code=f"RISK-ACTION-{utc_now():%Y%m%d%H%M%S%f}", code=f"RISK-ACTION-{utc_now():%Y%m%d%H%M%S%f}",
risk_event_id=record.id, risk_event_id=record.id,
@@ -193,7 +195,8 @@ class RiskActionMixin:
payload=payload, payload=payload,
) )
self.db.add(action_record) self.db.add(action_record)
AuditService(self.db).log( self.db.flush()
AuditService(self.db).record(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.RISK, source=AuditSource.RISK,
@@ -210,7 +213,7 @@ class RiskActionMixin:
}, },
) )
) )
EventService(self.db).emit( EventService(self.db).enqueue(
event_type=EventType.RISK_ACTION_RECORDED, event_type=EventType.RISK_ACTION_RECORDED,
source=EventSource.RISK, source=EventSource.RISK,
aggregate_type=EventAggregateType.RISK_EVENT, aggregate_type=EventAggregateType.RISK_EVENT,
@@ -226,7 +229,6 @@ class RiskActionMixin:
RiskEventActionKey.PAYLOAD: payload, RiskEventActionKey.PAYLOAD: payload,
}, },
idempotency_key=f"risk:{record.id}:{action_record.code}", idempotency_key=f"risk:{record.id}:{action_record.code}",
dispatch=True,
) )
return action_record return action_record

View File

@@ -3,6 +3,7 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.security import ensure_business_mutations_enabled
from app.modules.audit.constants import ( from app.modules.audit.constants import (
AuditAction, AuditAction,
AuditRiskLevel, AuditRiskLevel,
@@ -29,6 +30,7 @@ class RiskGenerationMixin:
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]: def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
"""Generate or refresh risk-event ledger entries from current signals.""" """Generate or refresh risk-event ledger entries from current signals."""
ensure_business_mutations_enabled()
payloads = self._build_event_payloads() payloads = self._build_event_payloads()
created = 0 created = 0
updated = 0 updated = 0
@@ -67,8 +69,7 @@ class RiskGenerationMixin:
} }
) )
self.db.commit() AuditService(self.db).record(
AuditService(self.db).log(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.RISK, source=AuditSource.RISK,
@@ -82,6 +83,7 @@ class RiskGenerationMixin:
}, },
) )
) )
self.db.commit()
return { return {
RiskGenerationResultKey.CREATED: created, RiskGenerationResultKey.CREATED: created,
RiskGenerationResultKey.UPDATED: updated, RiskGenerationResultKey.UPDATED: updated,

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue from app.core.constants import ActorValue
@@ -11,6 +11,14 @@ from app.modules.workflows.constants import WorkflowStatus
class WorkflowInstance(Base): class WorkflowInstance(Base):
__tablename__ = "workflow_instances" __tablename__ = "workflow_instances"
__table_args__ = (
UniqueConstraint(
"workflow_type",
"aggregate_type",
"aggregate_id",
name="uq_workflow_aggregate",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True) code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
@@ -35,7 +43,10 @@ class WorkflowAction(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True) code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
workflow_code: Mapped[str] = mapped_column(String(64), index=True) workflow_code: Mapped[str] = mapped_column(
ForeignKey("workflow_instances.code", ondelete="RESTRICT"),
index=True,
)
action: Mapped[str] = mapped_column(String(128), index=True) action: Mapped[str] = mapped_column(String(128), index=True)
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True) from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)

View File

@@ -4,12 +4,13 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import require_api_key
from app.modules.workflows.constants import WorkflowResponseKey from app.modules.workflows.constants import WorkflowResponseKey
from app.modules.workflows.schemas import WorkflowListRead, WorkflowRead
from app.modules.workflows.service import WorkflowService from app.modules.workflows.service import WorkflowService
router = APIRouter(dependencies=[Depends(require_api_key)]) router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("") @router.get("", response_model=WorkflowListRead)
def list_workflows( def list_workflows(
status: str | None = None, status: str | None = None,
workflow_type: str | None = None, workflow_type: str | None = None,
@@ -25,7 +26,7 @@ def list_workflows(
} }
@router.get("/{code}") @router.get("/{code}", response_model=dict[str, WorkflowRead])
def get_workflow( def get_workflow(
code: str, code: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -15,7 +15,8 @@ class WorkflowRead(BaseModel):
created_at: str created_at: str
updated_at: str updated_at: str
completed_at: str | None completed_at: str | None
actions: list[dict[str, Any]] | None = None
class WorkflowListRead(BaseModel): class WorkflowListRead(BaseModel):
items: list[dict[str, Any]] items: list[WorkflowRead]

View File

@@ -33,6 +33,7 @@ class WorkflowService:
action: str, action: str,
actor: str = ActorValue.SYSTEM, actor: str = ActorValue.SYSTEM,
payload: dict[str, Any] | None = None, payload: dict[str, Any] | None = None,
commit: bool = True,
) -> WorkflowInstance: ) -> WorkflowInstance:
aggregate_id_text = str(aggregate_id) if aggregate_id is not None else None aggregate_id_text = str(aggregate_id) if aggregate_id is not None else None
record = self.db.execute( record = self.db.execute(
@@ -92,8 +93,11 @@ class WorkflowService:
payload=payload or {}, payload=payload or {},
) )
) )
self.db.commit() if commit:
self.db.refresh(record) self.db.commit()
self.db.refresh(record)
else:
self.db.flush()
return record return record
def list_workflows( def list_workflows(

View File

@@ -1,6 +1,7 @@
from socket import gethostname from socket import gethostname
from typing import Any from typing import Any
from app.application.events import EventDispatchService
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.tasks.app import celery_app, settings from app.tasks.app import celery_app, settings
@@ -11,7 +12,6 @@ def dispatch_pending_events(
limit: int | None = None, limit: int | None = None,
actor: str = ActorValue.WORKER, actor: str = ActorValue.WORKER,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
from app.modules.events.services import EventService
from app.modules.observability.constants import HeartbeatComponent from app.modules.observability.constants import HeartbeatComponent
from app.modules.observability.service import ObservabilityService from app.modules.observability.service import ObservabilityService
@@ -22,7 +22,7 @@ def dispatch_pending_events(
instance_id=gethostname(), instance_id=gethostname(),
actor=actor, actor=actor,
) )
return EventService(db).dispatch_pending( return EventDispatchService(db).dispatch_pending(
limit=limit or settings.event_dispatch_batch_size, limit=limit or settings.event_dispatch_batch_size,
worker_id=f"{actor}:{gethostname()}", worker_id=f"{actor}:{gethostname()}",
) )

View File

@@ -1,9 +1,9 @@
from typing import Any from typing import Any
from app.core.background.task_queue.constants import TASK_RUN_LIFECYCLE from app.tasks.constants import TASK_RUN_LIFECYCLE
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService from app.application.pipelines import LifecyclePipelineService
from app.tasks.app import celery_app from app.tasks.app import celery_app

View File

@@ -1,8 +1,8 @@
from datetime import date from datetime import date
from app.core.background.task_queue.constants import TASK_RUN_MARKET_CLOSE from app.tasks.constants import TASK_RUN_MARKET_CLOSE
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.modules.market.pipeline import MarketPipelineService from app.application.pipelines import MarketPipelineService
from app.tasks.app import celery_app from app.tasks.app import celery_app

View File

@@ -1,7 +1,7 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from app.core.background.task_queue.constants import ( from app.tasks.constants import (
TASK_PUSH_ATTENDANCE_SUMMARY, TASK_PUSH_ATTENDANCE_SUMMARY,
TASK_PUSH_DAILY_BRIEF, TASK_PUSH_DAILY_BRIEF,
TASK_PUSH_PROJECT_WEEKLY, TASK_PUSH_PROJECT_WEEKLY,
@@ -90,13 +90,14 @@ def _push_report(
actor: str, actor: str,
push_run_code: str | None, push_run_code: str | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from app.application.delivery import ReportDeliveryService
from app.modules.reports.services import ReportService from app.modules.reports.services import ReportService
db = SessionLocal() db = SessionLocal()
try: try:
service = ReportService(db) service = ReportService(db)
report = build_report(service, actor) report = build_report(service, actor)
return service.push_report( return ReportDeliveryService(db).push_report(
report, report,
receive_id, receive_id,
receive_id_type, receive_id_type,

View File

@@ -1,6 +1,6 @@
from time import sleep from time import sleep
from app.core.background.scheduler import create_scheduler from app.application.scheduling import create_scheduler
def main() -> None: def main() -> None:

View File

@@ -5,21 +5,6 @@ dependencies:
- python=3.11 - python=3.11
- pip - pip
- pip: - pip:
- fastapi==0.115.6 - -r requirements.txt
- uvicorn[standard]==0.34.0
- sqlalchemy==2.0.36
- pymysql==1.1.1
- psycopg[binary]==3.2.3
- pydantic-settings==2.7.1
- python-dotenv==1.0.1
- alembic==1.14.0
- httpx==0.28.1
- lark-oapi==1.6.8
- apscheduler==3.10.4
- redis==5.2.1
- celery==5.4.0
- cryptography==44.0.0
- pandas==2.2.3
- pillow==11.0.0
- pytest==8.3.4 - pytest==8.3.4
- ruff==0.8.4 - ruff==0.8.4

16
requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
pymysql==1.1.1
psycopg[binary]==3.2.3
pydantic-settings==2.7.1
python-dotenv==1.0.1
alembic==1.14.0
httpx==0.28.1
lark-oapi==1.6.8
apscheduler==3.10.4
redis==5.2.1
celery==5.4.0
cryptography==44.0.0
pandas==2.2.3
pillow==11.0.0

View File

@@ -0,0 +1,75 @@
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
from app.core.config import Settings
from app.core.database import Base
from app.core.security import OperationsDisabledError
from app.modules.audit.models import AuditLog
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.events.models import DomainEvent
from app.modules.events.services import EventService
from app.modules.risk.services import RiskService
def test_audit_and_outbox_rollback_with_business_transaction() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine, expire_on_commit=False)
with session_factory() as db:
AuditService(db).record(AuditLogCreate(action="transaction.rollback"))
EventService(db).enqueue(
event_type="test.rollback",
source="pytest",
aggregate_type="test",
aggregate_id="rollback",
idempotency_key="test-transaction-rollback",
)
db.rollback()
with session_factory() as db:
assert db.scalar(select(func.count()).select_from(AuditLog)) == 0
assert db.scalar(select(func.count()).select_from(DomainEvent)) == 0
def test_business_service_enforces_read_only_policy(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
try:
with pytest.raises(OperationsDisabledError):
RiskService(Session()).generate_events(actor="pytest")
finally:
get_settings.cache_clear()
def test_production_settings_fail_closed() -> None:
with pytest.raises(ValueError, match="PostgreSQL"):
Settings(
app_env="production",
database_url="sqlite:///unsafe.db",
api_key="api",
audit_api_key="audit",
cors_origins=["https://internal.example.com"],
)
def test_migrations_match_sqlalchemy_metadata(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
database_path = tmp_path / "migration-check.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_path}")
get_settings.cache_clear()
config = Config("alembic.ini")
try:
command.upgrade(config, "head")
command.check(config)
finally:
get_settings.cache_clear()

View File

@@ -30,12 +30,12 @@ os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core.background.scheduler import create_scheduler from app.application.scheduling import create_scheduler
from app.core.database import Base, SessionLocal, engine from app.core.database import Base, SessionLocal, engine
from app.core.http.pagination import bounded_limit, bounded_offset from app.core.http.pagination import bounded_limit, bounded_offset
from app.core.http.responses import MaskedJSONResponse
from app.core.security import require_api_key, require_audit_api_key from app.core.security import require_api_key, require_audit_api_key
from app.main import _allow_cors_credentials, app from app.main import _allow_cors_credentials, app
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
from app.modules.ai_memory.constants import ( from app.modules.ai_memory.constants import (
AIMemoryPayloadKey, AIMemoryPayloadKey,
AIMemoryResponseKey, AIMemoryResponseKey,
@@ -48,7 +48,9 @@ from app.modules.events.constants import (
EventStatus, EventStatus,
EventType, EventType,
) )
from app.modules.events.models import DomainEvent
from app.modules.events.services import EventService from app.modules.events.services import EventService
from app.application.events import EventDispatchService
from app.modules.business.registry import get_domain_model from app.modules.business.registry import get_domain_model
from app.modules.business.models import ( from app.modules.business.models import (
Employee, Employee,
@@ -101,17 +103,18 @@ from app.modules.reports.constants import (
ReportTitle, ReportTitle,
ReportType, ReportType,
) )
from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService from app.application.pipelines import LifecyclePipelineService
from app.application.delivery import ReportDeliveryService
from app.modules.reports.chart import render_lifecycle_chart from app.modules.reports.chart import render_lifecycle_chart
from app.modules.reports.services import ReportService from app.modules.reports.services import ReportService
from app.modules.feishu.service import FeishuService from app.modules.feishu.service import FeishuService
from app.modules.feishu.commands import FeishuCommandService from app.application.feishu import FeishuCommandService
from app.modules.feishu.events import FeishuEventService from app.application.feishu.events import FeishuEventService, _audit_event_metadata
from app.modules.feishu.constants import FeishuEventSource from app.modules.feishu.constants import FeishuEventSource
from app.modules.risk.constants import RiskEventActionValue from app.modules.risk.constants import RiskEventActionValue
from app.modules.market.chart import render_market_chart from app.modules.market.chart import render_market_chart
from app.modules.market.service import MarketService, TushareClient, normalize_symbol from app.modules.market.service import MarketService, TushareClient, normalize_symbol
from app.modules.market.pipeline import MarketPipelineService from app.application.pipelines import MarketPipelineService
from app.modules.workflows.constants import WorkflowStatus, WorkflowType from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowInstance from app.modules.workflows.models import WorkflowInstance
@@ -208,6 +211,9 @@ def test_feishu_webhook_routes_message_event() -> None:
data = response.json() data = response.json()
assert data["handled"] is True assert data["handled"] is True
assert data["result"]["command"] == "risk_summary" assert data["result"]["command"] == "risk_summary"
audit_metadata = _audit_event_metadata(payload)
assert "content" not in json.dumps(audit_metadata)
assert "test-feishu-token" not in json.dumps(audit_metadata)
duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload) duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload)
assert duplicate_response.status_code == 200 assert duplicate_response.status_code == 200
@@ -220,7 +226,7 @@ def test_feishu_webhook_routes_message_event() -> None:
assert logs_response.status_code == 200 assert logs_response.status_code == 200
audit_payload = json.dumps(logs_response.json(), ensure_ascii=False) audit_payload = json.dumps(logs_response.json(), ensure_ascii=False)
assert "test-feishu-token" not in audit_payload assert "test-feishu-token" not in audit_payload
assert AUDIT_REDACTED_VALUE in audit_payload assert "evt-smoke-risk-001" in audit_payload
def test_feishu_rule_commands_create_list_disable_and_enable(monkeypatch) -> None: def test_feishu_rule_commands_create_list_disable_and_enable(monkeypatch) -> None:
@@ -371,7 +377,10 @@ def test_v3_event_idempotency_and_workflow_dispatch() -> None:
EventPayloadKey.STATUS: StatusValue.OPEN, EventPayloadKey.STATUS: StatusValue.OPEN,
}, },
idempotency_key="v3-risk-idempotency", idempotency_key="v3-risk-idempotency",
dispatch=True, )
event = EventDispatchService(db).dispatch_event(
event.event_id,
worker_id="pytest-idempotency",
) )
duplicate = service.emit( duplicate = service.emit(
event_type=EventType.RISK_ACTION_RECORDED, event_type=EventType.RISK_ACTION_RECORDED,
@@ -384,7 +393,10 @@ def test_v3_event_idempotency_and_workflow_dispatch() -> None:
EventPayloadKey.STATUS: StatusValue.OPEN, EventPayloadKey.STATUS: StatusValue.OPEN,
}, },
idempotency_key="v3-risk-idempotency", idempotency_key="v3-risk-idempotency",
dispatch=True, )
duplicate = EventDispatchService(db).dispatch_event(
duplicate.event_id,
worker_id="pytest-idempotency-duplicate",
) )
assert duplicate.event_id == event.event_id assert duplicate.event_id == event.event_id
assert event.status == EventStatus.PROCESSED assert event.status == EventStatus.PROCESSED
@@ -442,7 +454,7 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
"/api/v1/ai/ask", "/api/v1/ai/ask",
headers=headers, headers=headers,
json={ json={
"prompt": "Summarize quarterly cash planning for project memory smoke", "prompt": "Remember that project updates should use concise bullet summaries",
"context": { "context": {
AIMemoryPayloadKey.SCOPE: "project", AIMemoryPayloadKey.SCOPE: "project",
AIMemoryPayloadKey.SUBJECT: "P-MEM-SMOKE", AIMemoryPayloadKey.SUBJECT: "P-MEM-SMOKE",
@@ -467,7 +479,7 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
"/api/v1/ai/memory/recall", "/api/v1/ai/memory/recall",
headers=headers, headers=headers,
json={ json={
"query": "quarterly cash planning", "query": "concise bullet summaries",
"scope": "project", "scope": "project",
"subject": "P-MEM-SMOKE", "subject": "P-MEM-SMOKE",
}, },
@@ -476,6 +488,49 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
assert recall_response.json()[AIMemoryResponseKey.ITEMS] assert recall_response.json()[AIMemoryResponseKey.ITEMS]
def test_ai_memory_rejects_financial_facts_and_applies_retention() -> None:
response = client.post(
"/api/v1/ai/ask",
headers=headers,
json={
"prompt": "Remember the project cash flow and budget details for next quarter",
"context": {
AIMemoryPayloadKey.SCOPE: "project",
AIMemoryPayloadKey.SUBJECT: "P-MEM-FINANCIAL",
},
},
)
assert response.status_code == 200
memory_write = response.json()[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE]
assert memory_write[AIMemoryPayloadKey.STATUS] == AIMemoryStatus.REJECTED
rejected = client.get(
"/api/v1/ai/memory",
headers=headers,
params={
"scope": "project",
"subject": "P-MEM-FINANCIAL",
"status": AIMemoryStatus.REJECTED,
},
).json()[AIMemoryResponseKey.ITEMS]
assert rejected
assert rejected[0]["expires_at"] is not None
assert "cash flow" not in rejected[0][AIMemoryPayloadKey.CONTENT].lower()
def test_default_json_response_masks_sensitive_fields() -> None:
response = MaskedJSONResponse(
{
"token": "provider-token",
"nested": {"password": "provider-password", "status": "ok"},
}
)
payload = json.loads(response.body)
assert payload["token"] == "[MASKED]"
assert payload["nested"]["password"] == "[MASKED]"
assert payload["nested"]["status"] == "ok"
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None: def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
response = create_business_record( response = create_business_record(
"risk-events", "risk-events",
@@ -776,8 +831,8 @@ def test_new_ledgers_reports_and_risk_events() -> None:
def test_independent_feishu_report_schedules_and_tasks_are_registered() -> None: def test_independent_feishu_report_schedules_and_tasks_are_registered() -> None:
from app.core.background.scheduler import create_scheduler from app.application.scheduling import create_scheduler
from app.core.background.task_queue.constants import ( from app.tasks.constants import (
TASK_PUSH_ATTENDANCE_SUMMARY, TASK_PUSH_ATTENDANCE_SUMMARY,
TASK_PUSH_DAILY_BRIEF, TASK_PUSH_DAILY_BRIEF,
TASK_PUSH_PROJECT_WEEKLY, TASK_PUSH_PROJECT_WEEKLY,
@@ -979,6 +1034,17 @@ def test_v3_enterprise_analytics_returns_read_only_sections() -> None:
db = SessionLocal() db = SessionLocal()
try: try:
event = db.execute(
select(DomainEvent).where(
DomainEvent.idempotency_key
== f"enterprise-analytics:{data[EnterpriseAnalyticsKey.CODE]}"
)
).scalar_one()
assert event.status == EventStatus.PENDING
EventDispatchService(db).dispatch_event(
event.event_id,
worker_id="pytest-enterprise",
)
workflow = db.execute( workflow = db.execute(
select(WorkflowInstance).where( select(WorkflowInstance).where(
WorkflowInstance.workflow_type == WorkflowType.ENTERPRISE_ANALYTICS, WorkflowInstance.workflow_type == WorkflowType.ENTERPRISE_ANALYTICS,
@@ -1438,7 +1504,11 @@ def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
"ai_analysis": {"ok": True, "answer": "analysis"}, "ai_analysis": {"ok": True, "answer": "analysis"},
}, },
) )
monkeypatch.setattr(ReportService, "push_report", lambda self, *args, **kwargs: {"ok": True}) monkeypatch.setattr(
ReportDeliveryService,
"push_report",
lambda self, *args, **kwargs: {"ok": True},
)
db = SessionLocal() db = SessionLocal()
try: try:
@@ -1506,7 +1576,7 @@ def test_ai_unavailable_sends_notice_without_business_report(monkeypatch) -> Non
}, },
) )
monkeypatch.setattr( monkeypatch.setattr(
ReportService, ReportDeliveryService,
"push_report", "push_report",
lambda self, *args, **kwargs: pytest.fail("business report must not be sent"), lambda self, *args, **kwargs: pytest.fail("business report must not be sent"),
) )
@@ -1596,7 +1666,7 @@ def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) ->
monkeypatch.setattr(FeishuService, "send_card", fake_send_card) monkeypatch.setattr(FeishuService, "send_card", fake_send_card)
db = SessionLocal() db = SessionLocal()
try: try:
ReportService(db).push_report( ReportDeliveryService(db).push_report(
{ {
"title": "Lifecycle", "title": "Lifecycle",
"report_type": "daily", "report_type": "daily",
@@ -2190,6 +2260,7 @@ def test_market_closed_day_skips_quote_collection() -> None:
def test_market_scheduler_registers_close_and_weekly_jobs(monkeypatch) -> None: def test_market_scheduler_registers_close_and_weekly_jobs(monkeypatch) -> None:
monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true") monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true")
monkeypatch.setenv("READ_ONLY_MODE", "false")
get_settings.cache_clear() get_settings.cache_clear()
try: try:
scheduler = create_scheduler() scheduler = create_scheduler()
@@ -2199,6 +2270,7 @@ def test_market_scheduler_registers_close_and_weekly_jobs(monkeypatch) -> None:
assert "market_weekly_analysis" in job_ids assert "market_weekly_analysis" in job_ids
finally: finally:
monkeypatch.delenv("MARKET_ANALYSIS_ENABLED", raising=False) monkeypatch.delenv("MARKET_ANALYSIS_ENABLED", raising=False)
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
get_settings.cache_clear() get_settings.cache_clear()