```
feat: 添加仪表板路由和响应数据脱敏功能 - 添加了仪表板模块路由并集成到主路由器中 - 实现了敏感数据响应脱敏配置和功能 - 增加了 Feishu 审批卡片操作处理功能 - 支持通过任务队列异步推送日常简报和项目周报 - 添加了风险事件生成的任务队列支持 - 在 smoke 测试中增加了相关功能验证 refactor: 格式化模型注册模块导入列表 - 将单行导入列表改为多行格式以提高可读性 ```
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
54
app/core/masking.py
Normal 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
|
||||||
@@ -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
107
app/core/task_queue.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
|||||||
1
app/modules/dashboard/__init__.py
Normal file
1
app/modules/dashboard/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Basic operations dashboard module."""
|
||||||
18
app/modules/dashboard/routes.py
Normal file
18
app/modules/dashboard/routes.py
Normal 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())
|
||||||
61
app/modules/dashboard/service.py
Normal file
61
app/modules/dashboard/service.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
60
app/tasks.py
Normal 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()
|
||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user