feat: 添加仪表板路由和响应数据脱敏功能

- 添加了仪表板模块路由并集成到主路由器中
- 实现了敏感数据响应脱敏配置和功能
- 增加了 Feishu 审批卡片操作处理功能
- 支持通过任务队列异步推送日常简报和项目周报
- 添加了风险事件生成的任务队列支持
- 在 smoke 测试中增加了相关功能验证

refactor: 格式化模型注册模块导入列表

- 将单行导入列表改为多行格式以提高可读性
```
This commit is contained in:
2026-07-07 18:33:07 +08:00
parent fbd0aaa9e4
commit 4d09d8e2e3
21 changed files with 641 additions and 25 deletions

View File

@@ -19,7 +19,12 @@ target_metadata = Base.metadata
settings = get_settings() settings = get_settings()
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata. # Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models, feishu_models) _REGISTERED_MODEL_MODULES = (
approval_models,
audit_models,
business_models,
feishu_models,
)
def run_migrations_offline() -> None: def run_migrations_offline() -> None:

View File

@@ -19,7 +19,12 @@ branch_labels = None
depends_on = None depends_on = None
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata. # Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models, feishu_models) _REGISTERED_MODEL_MODULES = (
approval_models,
audit_models,
business_models,
feishu_models,
)
def upgrade() -> None: def upgrade() -> None:

View File

@@ -5,6 +5,7 @@ from app.modules.ai_agent.routes import router as ai_router
from app.modules.approvals.routes import router as approvals_router from app.modules.approvals.routes import router as approvals_router
from app.modules.audit.routes import router as audit_router from app.modules.audit.routes import router as audit_router
from app.modules.business.routes import router as business_router from app.modules.business.routes import router as business_router
from app.modules.dashboard.routes import router as dashboard_router
from app.modules.feishu.routes import router as feishu_router from app.modules.feishu.routes import router as feishu_router
from app.modules.legacy_mysql.routes import router as legacy_mysql_router from app.modules.legacy_mysql.routes import router as legacy_mysql_router
from app.modules.reports.routes import router as reports_router from app.modules.reports.routes import router as reports_router
@@ -21,6 +22,7 @@ def health_check() -> dict[str, str]:
api_router.include_router(business_router, prefix="/business", tags=["business"]) api_router.include_router(business_router, prefix="/business", tags=["business"])
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(legacy_mysql_router, prefix="/integrations/mysql", tags=["mysql"]) api_router.include_router(legacy_mysql_router, prefix="/integrations/mysql", tags=["mysql"])
api_router.include_router(feishu_router, prefix="/integrations/feishu", tags=["feishu"]) api_router.include_router(feishu_router, prefix="/integrations/feishu", tags=["feishu"])
api_router.include_router(ai_router, prefix="/ai", tags=["ai"]) api_router.include_router(ai_router, prefix="/ai", tags=["ai"])

View File

@@ -29,6 +29,7 @@ class Settings(BaseSettings):
approval_api_key: str | None = None approval_api_key: str | None = None
approval_api_actor: str = ActorValue.APPROVER approval_api_actor: str = ActorValue.APPROVER
cors_origins: list[str] = Field(default_factory=lambda: ["*"]) cors_origins: list[str] = Field(default_factory=lambda: ["*"])
mask_sensitive_responses: bool = True
database_url: str = "sqlite:///./company_ai.db" database_url: str = "sqlite:///./company_ai.db"
legacy_database_url: str | None = None legacy_database_url: str | None = None
@@ -63,6 +64,9 @@ class Settings(BaseSettings):
direct_llm_model: str = "gpt-4.1-mini" direct_llm_model: str = "gpt-4.1-mini"
scheduler_enabled: bool = False scheduler_enabled: bool = False
task_queue_enabled: bool = False
task_queue_always_eager: bool = False
celery_result_backend_url: str | None = None
daily_brief_cron_hour: int = 9 daily_brief_cron_hour: int = 9
daily_brief_cron_minute: int = 0 daily_brief_cron_minute: int = 0
weekly_project_report_day_of_week: str = "mon" weekly_project_report_day_of_week: str = "mon"

54
app/core/masking.py Normal file
View File

@@ -0,0 +1,54 @@
from typing import Any
from app.core.config import get_settings
MASKED_VALUE = "[MASKED]"
SENSITIVE_RESPONSE_KEYS = frozenset(
{
"account_number",
"api_key",
"bank_account",
"card_no",
"direct_llm_api_key",
"email",
"feishu_app_secret",
"feishu_verification_token",
"hermes_api_key",
"id_card",
"mobile",
"openclaw_gateway_token",
"password",
"payment_account",
"phone",
"secret",
"tenant_access_token",
"token",
}
)
def mask_configured(value: Any) -> Any:
"""Mask sensitive response fields when response masking is enabled."""
settings = get_settings()
if not settings.mask_sensitive_responses:
return value
return mask_sensitive(value)
def mask_sensitive(value: Any) -> Any:
if isinstance(value, dict):
masked: dict[str, Any] = {}
for key, item in value.items():
key_text = str(key)
if key_text.lower() in SENSITIVE_RESPONSE_KEYS:
masked[key_text] = MASKED_VALUE
else:
masked[key_text] = mask_sensitive(item)
return masked
if isinstance(value, list):
return [mask_sensitive(item) for item in value]
if isinstance(value, tuple):
return [mask_sensitive(item) for item in value]
return value

View File

@@ -15,6 +15,7 @@ def attach_scheduler(app: FastAPI) -> None:
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.core.task_queue import enqueue_daily_brief_push, enqueue_project_weekly_push
from app.modules.reports.service import ReportService from app.modules.reports.service import ReportService
scheduler = BackgroundScheduler(timezone="Asia/Shanghai") scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
@@ -29,6 +30,13 @@ def attach_scheduler(app: FastAPI) -> None:
and settings.feishu_app_secret and settings.feishu_app_secret
and settings.feishu_default_chat_id and settings.feishu_default_chat_id
): ):
if settings.task_queue_enabled:
app.state.last_daily_brief_dispatch = enqueue_daily_brief_push(
receive_id=settings.feishu_default_chat_id,
receive_id_type=FeishuReceiveIdType.CHAT_ID,
actor=ActorValue.SCHEDULER,
)
return
ReportService(db).push_report( ReportService(db).push_report(
report, report,
receive_id=settings.feishu_default_chat_id, receive_id=settings.feishu_default_chat_id,
@@ -48,6 +56,13 @@ def attach_scheduler(app: FastAPI) -> None:
and settings.feishu_app_secret and settings.feishu_app_secret
and settings.feishu_default_chat_id and settings.feishu_default_chat_id
): ):
if settings.task_queue_enabled:
app.state.last_project_weekly_dispatch = enqueue_project_weekly_push(
receive_id=settings.feishu_default_chat_id,
receive_id_type=FeishuReceiveIdType.CHAT_ID,
actor=ActorValue.SCHEDULER,
)
return
ReportService(db).push_report( ReportService(db).push_report(
report, report,
receive_id=settings.feishu_default_chat_id, receive_id=settings.feishu_default_chat_id,

107
app/core/task_queue.py Normal file
View File

@@ -0,0 +1,107 @@
from collections.abc import Callable
from typing import Any
from app.core.config import get_settings
from app.modules.feishu.constants import FeishuReceiveIdType
TASK_PUSH_DAILY_BRIEF = "reports.push_daily_brief"
TASK_PUSH_PROJECT_WEEKLY = "reports.push_project_weekly"
TASK_GENERATE_RISK_EVENTS = "risks.generate_events"
def dispatch_task(
task_name: str,
kwargs: dict[str, Any],
inline: Callable[[], Any],
) -> dict[str, Any]:
"""Dispatch a task through Celery when enabled, otherwise run inline."""
settings = get_settings()
if settings.task_queue_enabled:
from app.tasks import celery_app
async_result = celery_app.signature(task_name, kwargs=kwargs).apply_async()
return {
"queued": True,
"mode": "celery",
"task_name": task_name,
"task_id": async_result.id,
}
return {
"queued": False,
"mode": "inline",
"task_name": task_name,
"result": inline(),
}
def enqueue_daily_brief_push(
receive_id: str | None = None,
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
actor: str = "scheduler",
) -> dict[str, Any]:
def inline() -> Any:
from app.core.database import SessionLocal
from app.modules.reports.service import ReportService
db = SessionLocal()
try:
report = ReportService(db).daily_brief()
return ReportService(db).push_report(report, receive_id, receive_id_type, actor)
finally:
db.close()
return dispatch_task(
TASK_PUSH_DAILY_BRIEF,
{
"receive_id": receive_id,
"receive_id_type": receive_id_type,
"actor": actor,
},
inline,
)
def enqueue_project_weekly_push(
receive_id: str | None = None,
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
actor: str = "scheduler",
) -> dict[str, Any]:
def inline() -> Any:
from app.core.database import SessionLocal
from app.modules.reports.service import ReportService
db = SessionLocal()
try:
report = ReportService(db).project_weekly()
return ReportService(db).push_report(report, receive_id, receive_id_type, actor)
finally:
db.close()
return dispatch_task(
TASK_PUSH_PROJECT_WEEKLY,
{
"receive_id": receive_id,
"receive_id_type": receive_id_type,
"actor": actor,
},
inline,
)
def enqueue_risk_event_generation(actor: str = "scheduler") -> dict[str, Any]:
def inline() -> Any:
from app.core.database import SessionLocal
from app.modules.risk.service import RiskService
db = SessionLocal()
try:
return RiskService(db).generate_events(actor=actor)
finally:
db.close()
return dispatch_task(
TASK_GENERATE_RISK_EVENTS,
{"actor": actor},
inline,
)

View File

@@ -3,8 +3,15 @@ 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, require_approval_api_key from app.core.security import ApiPrincipal, require_api_key, require_approval_api_key
from app.modules.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead from app.modules.approvals.schemas import (
ApprovalCreate,
ApprovalDecision,
ApprovalRead,
PushApprovalCardRequest,
)
from app.modules.approvals.service import ApprovalService from app.modules.approvals.service import ApprovalService
from app.modules.feishu.constants import FeishuResponseKey
from app.modules.feishu.service import FeishuService
router = APIRouter(dependencies=[Depends(require_api_key)]) router = APIRouter(dependencies=[Depends(require_api_key)])
@@ -57,3 +64,28 @@ def reject(
principal: ApiPrincipal = Depends(require_approval_api_key), principal: ApiPrincipal = Depends(require_approval_api_key),
): ):
return ApprovalService(db).decide(ticket_id, principal.actor, False, payload.comment) return ApprovalService(db).decide(ticket_id, principal.actor, False, payload.comment)
@router.post("/{ticket_id}/push-feishu-card")
def push_feishu_approval_card(
ticket_id: str,
payload: PushApprovalCardRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
ticket = ApprovalService(db).get_by_ticket(ticket_id)
lines = [
f"- 单号:{ticket.ticket_id}",
f"- 领域:{ticket.domain}",
f"- 动作:{ticket.action}",
f"- 申请人:{ticket.applicant}",
f"- 理由:{ticket.reason or '-'}",
]
card = FeishuService.build_approval_card("审批请求", lines, ticket.ticket_id)
result = FeishuService(db).send_card(
card,
receive_id=payload.receive_id,
receive_id_type=payload.receive_id_type,
actor=principal.actor,
)
return {FeishuResponseKey.OK: True, FeishuResponseKey.PROVIDER_RESPONSE: result}

View File

@@ -4,6 +4,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.modules.feishu.constants import FeishuReceiveIdType
class ApprovalCreate(BaseModel): class ApprovalCreate(BaseModel):
@@ -19,6 +20,14 @@ class ApprovalDecision(BaseModel):
comment: str | None = None comment: str | None = None
class PushApprovalCardRequest(BaseModel):
receive_id: str | None = Field(
default=None,
description="chat_id or open_id depending on receive_id_type.",
)
receive_id_type: str = FeishuReceiveIdType.CHAT_ID
class ApprovalRead(BaseModel): class ApprovalRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -3,6 +3,7 @@ from fastapi import status as http_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.masking import mask_configured
from app.core.security import ApiPrincipal, require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.business.constants import BusinessField, BusinessResponseKey from app.modules.business.constants import BusinessField, BusinessResponseKey
from app.modules.business.registry import supported_domain_values from app.modules.business.registry import supported_domain_values
@@ -32,16 +33,22 @@ def list_records(
return { return {
BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.TOTAL: total, BusinessResponseKey.TOTAL: total,
BusinessResponseKey.ITEMS: items, BusinessResponseKey.ITEMS: mask_configured(items),
} }
@router.get("/{domain}/{record_id}") @router.get("/{domain}/{record_id}")
def get_record(domain: str, record_id: int, db: Session = Depends(get_db)) -> dict: def get_record(
domain: str,
record_id: int,
db: Session = Depends(get_db),
) -> dict:
try: try:
return { return {
BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: BusinessService(db).get_record(domain, record_id), BusinessResponseKey.DATA: mask_configured(
BusinessService(db).get_record(domain, record_id)
),
} }
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -63,7 +70,10 @@ def create_record(
) )
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: data} return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: mask_configured(data),
}
@router.patch("/{domain}/{record_id}") @router.patch("/{domain}/{record_id}")
@@ -84,4 +94,7 @@ def update_record(
) )
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: data} return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: mask_configured(data),
}

View File

@@ -0,0 +1 @@
"""Basic operations dashboard module."""

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.masking import mask_configured
from app.core.security import ApiPrincipal, require_api_key
from app.modules.dashboard.service import DashboardService
router = APIRouter()
@router.get("/summary")
def dashboard_summary(
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
_ = principal
return mask_configured(DashboardService(db).summary())

View File

@@ -0,0 +1,61 @@
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.modules.approvals.constants import ApprovalStatus
from app.modules.approvals.models import ApprovalRequest
from app.modules.audit.models import AuditLog
from app.modules.business.constants import DONE_STATUSES, PROJECT_CLOSED_STATUSES, StatusValue
from app.modules.business.models import Project, RiskEvent, WorkReport, WorkTask
from app.modules.business.service import serialize_model
from app.modules.risk.service import RiskService
class DashboardService:
"""Build lightweight operational dashboard data for V2."""
def __init__(self, db: Session):
self.db = db
self.risks = RiskService(db)
def summary(self) -> dict[str, Any]:
active_projects = self._count(Project, Project.status.notin_(PROJECT_CLOSED_STATUSES))
open_tasks = self._count(WorkTask, WorkTask.status.notin_(DONE_STATUSES))
pending_approvals = self._count(
ApprovalRequest,
ApprovalRequest.status == ApprovalStatus.PENDING,
)
open_risk_events = self._count(RiskEvent, RiskEvent.status == StatusValue.OPEN)
latest_reports = self.db.execute(
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
).scalars()
latest_audit_logs = self.db.execute(
select(AuditLog).order_by(AuditLog.id.desc()).limit(10)
).scalars()
risk_summary = self.risks.summary()
return {
"metrics": {
"active_projects": active_projects,
"open_tasks": open_tasks,
"pending_approvals": pending_approvals,
"open_risk_events": open_risk_events,
"risk_level": risk_summary["risk_level"],
"risk_score": float(risk_summary["risk_score"]),
},
"risk_counts": {
"overdue_tasks": len(risk_summary["overdue_tasks"]),
"delayed_projects": len(risk_summary["delayed_projects"]),
"over_budget_projects": len(risk_summary["over_budget_projects"]),
"fund_risks": len(risk_summary["fund_risks"]),
"supplier_risks": len(risk_summary["supplier_risks"]),
},
"latest_reports": [serialize_model(item) for item in latest_reports],
"latest_audit_logs": [serialize_model(item) for item in latest_audit_logs],
}
def _count(self, model: type, *conditions: Any) -> int:
stmt = select(func.count()).select_from(model)
for condition in conditions:
stmt = stmt.where(condition)
return int(self.db.execute(stmt).scalar() or 0)

View File

@@ -1,9 +1,12 @@
import json
from typing import Any from typing import Any
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.modules.approvals.service import ApprovalService
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.commands import FeishuCommandService
@@ -79,6 +82,36 @@ class FeishuEventService:
FeishuResponseKey.RESULT: result, FeishuResponseKey.RESULT: result,
} }
def handle_approval_card_action(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Handle Feishu interactive-card approval button callbacks."""
self.feishu.verify_event(payload)
value = _approval_action_value(payload)
ticket_id = str(value.get("ticket_id") or "").strip()
decision = str(value.get("decision") or value.get("action") or "").lower()
if not ticket_id or decision not in {"approve", "reject"}:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid Feishu approval action payload",
)
comment = value.get("comment")
actor = _approval_operator(payload)
ticket = ApprovalService(self.db).decide(
ticket_id,
actor,
approved=decision == "approve",
comment=str(comment) if comment is not None else None,
)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
FeishuResponseKey.RESULT: {
"ticket_id": ticket.ticket_id,
"status": ticket.status,
"approver": ticket.approver,
},
}
def _register_event(self, event_identity: dict[str, str | None]) -> bool: def _register_event(self, event_identity: dict[str, str | None]) -> bool:
receipt = FeishuEventReceipt( receipt = FeishuEventReceipt(
event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]), event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
@@ -123,3 +156,31 @@ def _event_identity(
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None, FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None, FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None,
} }
def _approval_action_value(payload: dict[str, Any]) -> dict[str, Any]:
action = payload.get("action") or {}
event = payload.get(FeishuPayloadKey.EVENT) or {}
event_action = event.get("action") or {}
value = action.get("value") or event_action.get("value") or payload.get("value") or {}
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return value if isinstance(value, dict) else {}
def _approval_operator(payload: dict[str, Any]) -> str:
operator = payload.get("operator") or (payload.get(FeishuPayloadKey.EVENT) or {}).get(
"operator"
) or {}
operator_id = operator.get("operator_id") or {}
return (
operator_id.get(FeishuPayloadKey.OPEN_ID)
or operator_id.get(FeishuPayloadKey.USER_ID)
or operator.get(FeishuPayloadKey.OPEN_ID)
or operator.get(FeishuPayloadKey.USER_ID)
or ActorValue.FEISHU
)

View File

@@ -30,7 +30,18 @@ async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dic
) )
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)]) @router.post("/approval-card-action")
async def feishu_approval_card_action(
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Handle Feishu interactive-card approval actions."""
payload = await request.json()
return FeishuEventService(db).handle_approval_card_action(payload)
@router.post("/send-text", response_model=FeishuSendResult)
def send_text( def send_text(
payload: FeishuTextMessage, payload: FeishuTextMessage,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -48,7 +59,7 @@ def send_text(
} }
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)]) @router.post("/send-card", response_model=FeishuSendResult)
def send_card( def send_card(
payload: FeishuCardMessage, payload: FeishuCardMessage,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -69,7 +80,6 @@ def send_card(
@router.post( @router.post(
"/commands/preview", "/commands/preview",
response_model=FeishuCommandResult, response_model=FeishuCommandResult,
dependencies=[Depends(require_api_key)],
) )
def preview_command( def preview_command(
payload: FeishuCommandRequest, payload: FeishuCommandRequest,

View File

@@ -109,3 +109,37 @@ class FeishuService:
} }
], ],
} }
@staticmethod
def build_approval_card(
title: str,
lines: list[str],
ticket_id: str,
) -> dict[str, Any]:
card = FeishuService.build_basic_card(title, lines)
card[FeishuPayloadKey.ELEMENTS].append(
{
FeishuPayloadKey.TAG: "action",
"actions": [
{
FeishuPayloadKey.TAG: "button",
FeishuPayloadKey.TEXT: {
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
FeishuPayloadKey.CONTENT: "批准",
},
"type": "primary",
"value": {"ticket_id": ticket_id, "decision": "approve"},
},
{
FeishuPayloadKey.TAG: "button",
FeishuPayloadKey.TEXT: {
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
FeishuPayloadKey.CONTENT: "拒绝",
},
"type": "danger",
"value": {"ticket_id": ticket_id, "decision": "reject"},
},
],
}
)
return card

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Query
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.masking import mask_configured
from app.core.security import ApiPrincipal, require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.legacy_mysql.schemas import ( from app.modules.legacy_mysql.schemas import (
LegacyProjectSyncRequest, LegacyProjectSyncRequest,
@@ -15,16 +16,23 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/health") @router.get("/health")
def mysql_health(db: Session = Depends(get_db)) -> dict[str, str]: def mysql_health(
db: Session = Depends(get_db),
) -> dict[str, str]:
return LegacyMySQLService(db).health() return LegacyMySQLService(db).health()
@router.post("/query", response_model=QueryResult) @router.post("/query", response_model=QueryResult)
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict: def readonly_query(
payload: ReadonlyQueryRequest,
db: Session = Depends(get_db),
) -> dict:
service = LegacyMySQLService(db) service = LegacyMySQLService(db)
if payload.sql: if payload.sql:
return service.execute_readonly(payload.sql, payload.params, payload.limit) result = service.execute_readonly(payload.sql, payload.params, payload.limit)
return service.execute_allowed_query(payload.query_name, payload.params, payload.limit) else:
result = service.execute_allowed_query(payload.query_name, payload.params, payload.limit)
return mask_configured(result)
@router.get("/projects", response_model=QueryResult) @router.get("/projects", response_model=QueryResult)
@@ -32,7 +40,7 @@ def default_project_query(
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 LegacyMySQLService(db).fetch_default_projects(limit=limit) return mask_configured(LegacyMySQLService(db).fetch_default_projects(limit=limit))
@router.post("/projects/sync", response_model=LegacyProjectSyncResult) @router.post("/projects/sync", response_model=LegacyProjectSyncResult)
@@ -41,7 +49,7 @@ def sync_projects(
db: Session = Depends(get_db), db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key), principal: ApiPrincipal = Depends(require_api_key),
) -> dict: ) -> dict:
return LegacyMySQLService(db).sync_projects( result = LegacyMySQLService(db).sync_projects(
source_query=payload.source_query, source_query=payload.source_query,
source_query_name=payload.source_query_name, source_query_name=payload.source_query_name,
field_map=payload.field_map, field_map=payload.field_map,
@@ -49,3 +57,4 @@ def sync_projects(
dry_run=payload.dry_run, dry_run=payload.dry_run,
actor=principal.actor, actor=principal.actor,
) )
return mask_configured(result)

View File

@@ -5,6 +5,7 @@ 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.core.task_queue import enqueue_daily_brief_push, enqueue_project_weekly_push
from app.modules.reports.schemas import ( from app.modules.reports.schemas import (
PushReportRequest, PushReportRequest,
ReportResponse, ReportResponse,
@@ -16,12 +17,16 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/daily-brief", response_model=ReportResponse) @router.get("/daily-brief", response_model=ReportResponse)
def daily_brief(db: Session = Depends(get_db)) -> dict: def daily_brief(
db: Session = Depends(get_db),
) -> dict:
return ReportService(db).daily_brief() return ReportService(db).daily_brief()
@router.get("/project-weekly", response_model=ReportResponse) @router.get("/project-weekly", response_model=ReportResponse)
def project_weekly(db: Session = Depends(get_db)) -> dict: def project_weekly(
db: Session = Depends(get_db),
) -> dict:
return ReportService(db).project_weekly() return ReportService(db).project_weekly()
@@ -99,3 +104,27 @@ def push_project_weekly(
payload.receive_id_type, payload.receive_id_type,
principal.actor, principal.actor,
) )
@router.post("/daily-brief/enqueue")
def enqueue_daily_brief(
payload: PushReportRequest,
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return enqueue_daily_brief_push(
receive_id=payload.receive_id,
receive_id_type=payload.receive_id_type,
actor=principal.actor,
)
@router.post("/project-weekly/enqueue")
def enqueue_project_weekly(
payload: PushReportRequest,
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return enqueue_project_weekly_push(
receive_id=payload.receive_id,
receive_id_type=payload.receive_id_type,
actor=principal.actor,
)

View File

@@ -3,6 +3,7 @@ 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.core.task_queue import enqueue_risk_event_generation
from app.modules.risk.constants import RiskGenerationResultKey from app.modules.risk.constants import RiskGenerationResultKey
from app.modules.risk.service import RiskService from app.modules.risk.service import RiskService
@@ -10,32 +11,44 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/summary") @router.get("/summary")
def risk_summary(db: Session = Depends(get_db)) -> dict: def risk_summary(
db: Session = Depends(get_db),
) -> dict:
return RiskService(db).summary() return RiskService(db).summary()
@router.get("/overdue-tasks") @router.get("/overdue-tasks")
def overdue_tasks(db: Session = Depends(get_db)) -> dict: def overdue_tasks(
db: Session = Depends(get_db),
) -> dict:
return {RiskGenerationResultKey.ITEMS: RiskService(db).overdue_tasks()} return {RiskGenerationResultKey.ITEMS: RiskService(db).overdue_tasks()}
@router.get("/delayed-projects") @router.get("/delayed-projects")
def delayed_projects(db: Session = Depends(get_db)) -> dict: def delayed_projects(
db: Session = Depends(get_db),
) -> dict:
return {RiskGenerationResultKey.ITEMS: RiskService(db).delayed_projects()} return {RiskGenerationResultKey.ITEMS: RiskService(db).delayed_projects()}
@router.get("/over-budget-projects") @router.get("/over-budget-projects")
def over_budget_projects(db: Session = Depends(get_db)) -> dict: def over_budget_projects(
db: Session = Depends(get_db),
) -> dict:
return {RiskGenerationResultKey.ITEMS: RiskService(db).over_budget_projects()} return {RiskGenerationResultKey.ITEMS: RiskService(db).over_budget_projects()}
@router.get("/funds") @router.get("/funds")
def fund_risks(db: Session = Depends(get_db)) -> dict: def fund_risks(
db: Session = Depends(get_db),
) -> dict:
return {RiskGenerationResultKey.ITEMS: RiskService(db).fund_risks()} return {RiskGenerationResultKey.ITEMS: RiskService(db).fund_risks()}
@router.get("/suppliers") @router.get("/suppliers")
def supplier_risks(db: Session = Depends(get_db)) -> dict: def supplier_risks(
db: Session = Depends(get_db),
) -> dict:
return {RiskGenerationResultKey.ITEMS: RiskService(db).supplier_risks()} return {RiskGenerationResultKey.ITEMS: RiskService(db).supplier_risks()}
@@ -59,3 +72,10 @@ def generate_risk_events(
principal: ApiPrincipal = Depends(require_api_key), principal: ApiPrincipal = Depends(require_api_key),
) -> dict: ) -> dict:
return RiskService(db).generate_events(actor=principal.actor) return RiskService(db).generate_events(actor=principal.actor)
@router.post("/events/enqueue")
def enqueue_risk_events(
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return enqueue_risk_event_generation(actor=principal.actor)

60
app/tasks.py Normal file
View File

@@ -0,0 +1,60 @@
from typing import Any
from celery import Celery
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.database import SessionLocal
from app.modules.feishu.constants import FeishuReceiveIdType
settings = get_settings()
celery_app = Celery(
"company_ai_platform",
broker=settings.redis_url,
backend=settings.celery_result_backend_url or settings.redis_url,
)
celery_app.conf.task_always_eager = settings.task_queue_always_eager
@celery_app.task(name="reports.push_daily_brief")
def push_daily_brief(
receive_id: str | None = None,
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
actor: str = ActorValue.SCHEDULER,
) -> dict[str, Any]:
from app.modules.reports.service import ReportService
db = SessionLocal()
try:
report = ReportService(db).daily_brief()
return ReportService(db).push_report(report, receive_id, receive_id_type, actor)
finally:
db.close()
@celery_app.task(name="reports.push_project_weekly")
def push_project_weekly(
receive_id: str | None = None,
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
actor: str = ActorValue.SCHEDULER,
) -> dict[str, Any]:
from app.modules.reports.service import ReportService
db = SessionLocal()
try:
report = ReportService(db).project_weekly()
return ReportService(db).push_report(report, receive_id, receive_id_type, actor)
finally:
db.close()
@celery_app.task(name="risks.generate_events")
def generate_risk_events(actor: str = ActorValue.SCHEDULER) -> dict[str, Any]:
from app.modules.risk.service import RiskService
db = SessionLocal()
try:
return RiskService(db).generate_events(actor=actor)
finally:
db.close()

View File

@@ -199,6 +199,34 @@ def test_config_and_pagination_guardrails() -> None:
assert negative_offset_response.status_code == 422 assert negative_offset_response.status_code == 422
def test_dashboard_and_response_masking() -> None:
expense_response = client.post(
"/api/v1/business/expenses",
headers=headers,
json={
"data": {
"code": "EXP-MASK-001",
"expense_type": "办公",
"amount": 20,
"payment_account": "6222000000000000",
},
},
)
assert expense_response.status_code == 200
masked_response = client.get("/api/v1/business/expenses", headers=headers)
assert masked_response.status_code == 200
masked_items = masked_response.json()["items"]
assert any(item["code"] == "EXP-MASK-001" for item in masked_items)
assert next(
item["payment_account"] for item in masked_items if item["code"] == "EXP-MASK-001"
) == "[MASKED]"
dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers)
assert dashboard_response.status_code == 200
assert "metrics" in dashboard_response.json()
def test_approval_gate_for_high_risk_update() -> None: def test_approval_gate_for_high_risk_update() -> None:
create_payload = { create_payload = {
"code": "FUND-SMOKE-001", "code": "FUND-SMOKE-001",
@@ -359,6 +387,40 @@ def test_approval_gate_for_high_risk_update() -> None:
assert reuse_update_response.status_code == 403 assert reuse_update_response.status_code == 403
def test_feishu_approval_card_action_approves_ticket() -> None:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": "feishu-card-test",
"action": "update:fund-accounts",
"reason": "Card action smoke test",
"payload": {"current_balance": 300},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
callback_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
json={
"token": "test-feishu-token",
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
"action": {
"value": {
"ticket_id": ticket_id,
"decision": "approve",
"comment": "approved from card",
}
},
},
)
assert callback_response.status_code == 200
assert callback_response.json()["result"]["status"] == "approved"
assert callback_response.json()["result"]["approver"] == "ou_card_approver"
def test_new_ledgers_reports_and_risk_events() -> None: def test_new_ledgers_reports_and_risk_events() -> None:
domains_response = client.get("/api/v1/business/domains", headers=headers) domains_response = client.get("/api/v1/business/domains", headers=headers)
assert domains_response.status_code == 200 assert domains_response.status_code == 200
@@ -419,6 +481,11 @@ def test_new_ledgers_reports_and_risk_events() -> None:
assert risk_response.status_code == 200 assert risk_response.status_code == 200
assert risk_response.json()["created"] >= 1 assert risk_response.json()["created"] >= 1
enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers)
assert enqueue_response.status_code == 200
assert enqueue_response.json()["queued"] is False
assert "result" in enqueue_response.json()
events_response = client.get("/api/v1/risks/events?status=open", headers=headers) events_response = client.get("/api/v1/risks/events?status=open", headers=headers)
assert events_response.status_code == 200 assert events_response.status_code == 200
assert any(item["risk_type"] == "overdue_task" for item in events_response.json()["items"]) assert any(item["risk_type"] == "overdue_task" for item in events_response.json()["items"])