```
feat(core): 添加API认证主体配置和安全验证 - 在Settings中添加api_actor字段,用于标识API调用方身份 - 创建ApiPrincipal数据类来表示服务主体 - 修改require_api_key函数返回认证的服务主体信息 - 更新配置文件引入ActorValue常量 feat(ai_agent): 增强OpenClaw工具调用的安全性检查 - 实现_openclaw_allowed_tools和openclaw_allowed_actions配置项 - 添加CSV列表解析验证器 - 实现工具和操作权限检查方法_ensure_tool_allowed - 在工具调用前验证允许的工具和操作类型 feat(security): 强化API密钥认证和审计安全性 - 更新require_api_key函数在缺少API_KEY时抛出异常 - 在AI代理、审批、飞书等模块的路由中统一使用ApiPrincipal获取调用方信息 - 替换硬编码的ActorValue.API为动态的principal.actor feat(audit): 实现安全审计负载脱敏处理 - 添加敏感键名集合AI_AUDIT_SENSITIVE_KEYS - 实现审计安全负载处理函数_audit_safe_payload - 支持深度遍历、文本截断、序列限制和敏感信息脱敏 - 在AI服务的审计日志中应用安全负载处理 feat(approval): 完善审批流程的申请人身份验证 - 更新审批创建接口使用认证主体作为申请人 - 使用utc_now替换datetime.utcnow确保时间一致性 - 修复审批逻辑中的条件判断问题 feat(business): 加强业务领域高风险操作的审批控制 - 为高风险域创建统一的审批验证方法_ensure_approved - 在创建和更新操作中强制要求审批票证 - 为项目同步功能添加认证主体参数 feat(config): 统一时间处理使用UTC时间函数 - 创建并使用utc_now函数替代datetime.utcnow - 在审批、审计、业务、遗留数据等模块中更新时间戳处理 feat(constants): 扩展风险事件类型和报告指标 - 添加新风险事件类型到GENERATED_RISK_EVENT_TYPES - 为报告模块添加外部开放和高风险事件指标 refactor(feishu): 增强飞书验证令牌安全检查 - 确保飞书验证令牌配置存在时才接受请求 - 修正令牌验证逻辑以提高安全性 ```
This commit is contained in:
@@ -111,6 +111,7 @@ class OpenClawAdapter(AIAdapter):
|
||||
) -> dict[str, Any]:
|
||||
"""Invoke one OpenClaw Gateway tool through `/tools/invoke`."""
|
||||
|
||||
self._ensure_tool_allowed(tool, action)
|
||||
payload = {
|
||||
AIHttpPayloadKey.TOOL: tool,
|
||||
AIHttpPayloadKey.ACTION: action,
|
||||
@@ -135,6 +136,12 @@ class OpenClawAdapter(AIAdapter):
|
||||
AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token)
|
||||
}
|
||||
|
||||
def _ensure_tool_allowed(self, tool: str, action: str) -> None:
|
||||
if tool not in set(self.settings.openclaw_allowed_tools):
|
||||
raise HTTPException(status_code=403, detail="OpenClaw tool is not allowed")
|
||||
if action not in set(self.settings.openclaw_allowed_actions):
|
||||
raise HTTPException(status_code=403, detail="OpenClaw action is not allowed")
|
||||
|
||||
|
||||
class HermesAdapter(AIAdapter):
|
||||
"""Adapter for the Hermes OpenAI-compatible agent endpoint."""
|
||||
|
||||
@@ -126,3 +126,24 @@ NOOP_PROVIDER_ANSWER = (
|
||||
OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed."
|
||||
DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured"
|
||||
UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response"
|
||||
|
||||
AI_AUDIT_REDACTED_VALUE = "[REDACTED]"
|
||||
AI_AUDIT_TRUNCATED_VALUE = "[TRUNCATED]"
|
||||
AI_AUDIT_MAX_TEXT_LENGTH = 1000
|
||||
AI_AUDIT_MAX_SEQUENCE_ITEMS = 20
|
||||
AI_AUDIT_MAX_DEPTH = 4
|
||||
AI_AUDIT_SENSITIVE_KEYS = frozenset(
|
||||
{
|
||||
"authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"access_token",
|
||||
"tenant_access_token",
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"openclaw_gateway_token",
|
||||
"hermes_api_key",
|
||||
"direct_llm_api_key",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.ai_agent.schemas import (
|
||||
AIAskRequest,
|
||||
AIAskResponse,
|
||||
@@ -17,43 +17,64 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.post("/ask", response_model=AIAskResponse)
|
||||
def ask(payload: AIAskRequest, db: Session = Depends(get_db)) -> dict:
|
||||
return AIService(db).ask(payload.prompt, payload.context, payload.actor, payload.source)
|
||||
def ask(
|
||||
payload: AIAskRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return AIService(db).ask(
|
||||
payload.prompt,
|
||||
payload.context,
|
||||
actor=principal.actor,
|
||||
source=AuditSource.API,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/provider-health")
|
||||
def provider_health(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict:
|
||||
return AIService(db).provider_health(actor=actor)
|
||||
def provider_health(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return AIService(db).provider_health(actor=principal.actor)
|
||||
|
||||
|
||||
@router.post("/openclaw/tools/invoke")
|
||||
def invoke_openclaw_tool(
|
||||
payload: OpenClawToolInvokeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return AIService(db).invoke_openclaw_tool(
|
||||
tool=payload.tool,
|
||||
action=payload.action,
|
||||
args=payload.args,
|
||||
session_key=payload.session_key,
|
||||
actor=payload.actor,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/draft-policy", response_model=AIAskResponse)
|
||||
def draft_policy(payload: DraftPolicyRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def draft_policy(
|
||||
payload: DraftPolicyRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return AIService(db).draft_policy(
|
||||
title=payload.title,
|
||||
policy_type=payload.policy_type,
|
||||
requirements=payload.requirements,
|
||||
actor=payload.actor,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/investment-research", response_model=AIAskResponse)
|
||||
def investment_research(payload: InvestmentResearchRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def investment_research(
|
||||
payload: InvestmentResearchRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return AIService(db).draft_investment_research(
|
||||
symbol_or_topic=payload.symbol_or_topic,
|
||||
risk_preference=payload.risk_preference,
|
||||
actor=payload.actor,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,12 @@ from app.core.config import get_settings
|
||||
from app.modules.ai_agent.adapters import HermesAdapter, OpenClawAdapter, get_adapter
|
||||
from app.modules.ai_agent.constants import (
|
||||
AIDefault,
|
||||
AI_AUDIT_MAX_DEPTH,
|
||||
AI_AUDIT_MAX_SEQUENCE_ITEMS,
|
||||
AI_AUDIT_MAX_TEXT_LENGTH,
|
||||
AI_AUDIT_REDACTED_VALUE,
|
||||
AI_AUDIT_SENSITIVE_KEYS,
|
||||
AI_AUDIT_TRUNCATED_VALUE,
|
||||
AIProviderName,
|
||||
AIRequestKey,
|
||||
AIResponseKey,
|
||||
@@ -50,11 +56,11 @@ class AIService:
|
||||
action=AuditAction.AI_ASK,
|
||||
target_type=AuditTargetType.AI,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
request_payload={
|
||||
request_payload=_audit_safe_payload({
|
||||
AIRequestKey.PROMPT: prompt,
|
||||
AIRequestKey.CONTEXT: context or {},
|
||||
},
|
||||
response_payload=response,
|
||||
}),
|
||||
response_payload=_audit_safe_payload(response),
|
||||
)
|
||||
)
|
||||
return response
|
||||
@@ -90,7 +96,7 @@ class AIService:
|
||||
action=AuditAction.AI_PROVIDER_HEALTH,
|
||||
target_type=AuditTargetType.AI,
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
response_payload=response,
|
||||
response_payload=_audit_safe_payload(response),
|
||||
)
|
||||
)
|
||||
return response
|
||||
@@ -113,13 +119,13 @@ class AIService:
|
||||
target_type=AuditTargetType.OPENCLAW_TOOL,
|
||||
target_id=tool,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload={
|
||||
request_payload=_audit_safe_payload({
|
||||
"tool": tool,
|
||||
"action": action,
|
||||
"args": args or {},
|
||||
"session_key": session_key,
|
||||
},
|
||||
response_payload=result,
|
||||
}),
|
||||
response_payload=_audit_safe_payload(result),
|
||||
)
|
||||
)
|
||||
return response
|
||||
@@ -167,3 +173,26 @@ class AIService:
|
||||
},
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
|
||||
def _audit_safe_payload(value: Any, depth: int = 0) -> Any:
|
||||
if depth >= AI_AUDIT_MAX_DEPTH:
|
||||
return AI_AUDIT_TRUNCATED_VALUE
|
||||
if isinstance(value, dict):
|
||||
safe: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
key_text = str(key)
|
||||
if key_text.lower() in AI_AUDIT_SENSITIVE_KEYS:
|
||||
safe[key_text] = AI_AUDIT_REDACTED_VALUE
|
||||
else:
|
||||
safe[key_text] = _audit_safe_payload(item, depth + 1)
|
||||
return safe
|
||||
if isinstance(value, (list, tuple)):
|
||||
items = list(value[:AI_AUDIT_MAX_SEQUENCE_ITEMS])
|
||||
safe_items = [_audit_safe_payload(item, depth + 1) for item in items]
|
||||
if len(value) > AI_AUDIT_MAX_SEQUENCE_ITEMS:
|
||||
safe_items.append(AI_AUDIT_TRUNCATED_VALUE)
|
||||
return safe_items
|
||||
if isinstance(value, str) and len(value) > AI_AUDIT_MAX_TEXT_LENGTH:
|
||||
return value[:AI_AUDIT_MAX_TEXT_LENGTH] + AI_AUDIT_TRUNCATED_VALUE
|
||||
return value
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import ApprovalStatus
|
||||
|
||||
|
||||
@@ -22,10 +23,10 @@ class ApprovalRequest(Base):
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
|
||||
@@ -10,8 +10,12 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.post("", response_model=ApprovalRead)
|
||||
def create_approval(payload: ApprovalCreate, db: Session = Depends(get_db)):
|
||||
return ApprovalService(db).create(payload)
|
||||
def create_approval(
|
||||
payload: ApprovalCreate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
):
|
||||
return ApprovalService(db).create(payload, applicant=principal.actor)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApprovalRead])
|
||||
@@ -29,10 +33,20 @@ def get_approval(ticket_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/approve", response_model=ApprovalRead)
|
||||
def approve(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)):
|
||||
return ApprovalService(db).decide(ticket_id, payload.approver, True, payload.comment)
|
||||
def approve(
|
||||
ticket_id: str,
|
||||
payload: ApprovalDecision,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
):
|
||||
return ApprovalService(db).decide(ticket_id, principal.actor, True, payload.comment)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reject", response_model=ApprovalRead)
|
||||
def reject(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)):
|
||||
return ApprovalService(db).decide(ticket_id, payload.approver, False, payload.comment)
|
||||
def reject(
|
||||
ticket_id: str,
|
||||
payload: ApprovalDecision,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
):
|
||||
return ApprovalService(db).decide(ticket_id, principal.actor, False, payload.comment)
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import ApprovalActionValue, ApprovalStatus
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.approvals.schemas import ApprovalCreate
|
||||
@@ -20,13 +21,13 @@ class ApprovalService:
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def create(self, payload: ApprovalCreate) -> ApprovalRequest:
|
||||
def create(self, payload: ApprovalCreate, applicant: str) -> ApprovalRequest:
|
||||
ticket = ApprovalRequest(
|
||||
ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}",
|
||||
domain=payload.domain,
|
||||
record_id=payload.record_id,
|
||||
action=payload.action,
|
||||
applicant=payload.applicant,
|
||||
applicant=applicant,
|
||||
reason=payload.reason,
|
||||
payload=json.dumps(payload.payload, ensure_ascii=False, default=str),
|
||||
)
|
||||
@@ -35,7 +36,7 @@ class ApprovalService:
|
||||
self.db.refresh(ticket)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=payload.applicant,
|
||||
actor=applicant,
|
||||
source=AuditSource.APPROVAL,
|
||||
action=AuditAction.APPROVAL_CREATE,
|
||||
target_type=payload.domain,
|
||||
@@ -77,9 +78,7 @@ class ApprovalService:
|
||||
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
|
||||
ticket.approver = approver
|
||||
ticket.decision_comment = comment
|
||||
from datetime import datetime
|
||||
|
||||
ticket.decided_at = datetime.utcnow()
|
||||
ticket.decided_at = utc_now()
|
||||
self.db.commit()
|
||||
self.db.refresh(ticket)
|
||||
self.audit.log(
|
||||
@@ -108,7 +107,9 @@ class ApprovalService:
|
||||
return False
|
||||
if ticket.domain != domain:
|
||||
return False
|
||||
if ticket.record_id and record_id is not None and str(ticket.record_id) != str(record_id):
|
||||
if ticket.record_id and (
|
||||
record_id is None or str(ticket.record_id) != str(record_id)
|
||||
):
|
||||
return False
|
||||
return ticket.action in {
|
||||
action,
|
||||
|
||||
@@ -27,12 +27,14 @@ class AuditSource(StrEnum):
|
||||
FEISHU = "feishu"
|
||||
APPROVAL = "approval"
|
||||
LEGACY_MYSQL = "legacy_mysql"
|
||||
REPORTS = "reports"
|
||||
|
||||
|
||||
class AuditTargetType(StrEnum):
|
||||
AI = "ai"
|
||||
OPENCLAW_TOOL = "openclaw_tool"
|
||||
RISK_EVENTS = "risk-events"
|
||||
WORK_REPORTS = "work-reports"
|
||||
|
||||
|
||||
class AuditStatus(StrEnum):
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus
|
||||
|
||||
|
||||
@@ -21,4 +22,4 @@ class AuditLog(Base):
|
||||
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=AuditStatus.SUCCESS, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
|
||||
@@ -115,3 +115,12 @@ ATTENDANCE_ABNORMAL_STATUSES = frozenset(
|
||||
)
|
||||
SUPPLIER_RISK_LEVELS = frozenset({RiskLevel.MEDIUM, RiskLevel.HIGH})
|
||||
CLOSED_RISK_STATUSES = frozenset({StatusValue.CLOSED, StatusValue.RESOLVED})
|
||||
GENERATED_RISK_EVENT_TYPES = frozenset(
|
||||
{
|
||||
RiskEventType.OVERDUE_TASK,
|
||||
RiskEventType.DELAYED_PROJECT,
|
||||
RiskEventType.OVER_BUDGET_PROJECT,
|
||||
RiskEventType.FUND_SAFETY_LINE,
|
||||
RiskEventType.SUPPLIER_RISK,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.business.constants import (
|
||||
AccountType,
|
||||
PriorityValue,
|
||||
@@ -17,9 +18,9 @@ from app.modules.business.constants import (
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
DateTime, default=utc_now, onupdate=utc_now
|
||||
)
|
||||
|
||||
|
||||
@@ -230,7 +231,7 @@ class RiskEvent(Base, TimestampMixin):
|
||||
source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
detected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
detected_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
mitigation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -245,7 +246,7 @@ class LegacySyncRun(Base, TimestampMixin):
|
||||
domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||
source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=StatusValue.RUNNING, index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.business.registry import DOMAIN_MODELS
|
||||
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
|
||||
from app.modules.business.service import BusinessService
|
||||
@@ -43,9 +43,15 @@ def create_record(
|
||||
domain: str,
|
||||
payload: DomainRecordCreate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
try:
|
||||
data = BusinessService(db).create_record(domain, payload.data, payload.actor)
|
||||
data = BusinessService(db).create_record(
|
||||
domain,
|
||||
payload.data,
|
||||
principal.actor,
|
||||
payload.approval_ticket_id,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "data": data}
|
||||
@@ -57,13 +63,14 @@ def update_record(
|
||||
record_id: int,
|
||||
payload: DomainRecordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
try:
|
||||
data = BusinessService(db).update_record(
|
||||
domain,
|
||||
record_id,
|
||||
payload.data,
|
||||
actor=payload.actor,
|
||||
actor=principal.actor,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
except KeyError as exc:
|
||||
|
||||
@@ -8,6 +8,10 @@ from app.core.constants import ActorValue
|
||||
class DomainRecordCreate(BaseModel):
|
||||
data: dict[str, Any] = Field(..., description="Domain fields to create.")
|
||||
actor: str = ActorValue.API
|
||||
approval_ticket_id: str | None = Field(
|
||||
default=None,
|
||||
description="Required by policy for high-risk creates such as funds or performance.",
|
||||
)
|
||||
|
||||
|
||||
class DomainRecordUpdate(BaseModel):
|
||||
|
||||
@@ -102,7 +102,10 @@ class BusinessService:
|
||||
domain: str,
|
||||
data: dict[str, Any],
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
self._ensure_approved(approval_ticket_id, domain, None, f"create:{domain}")
|
||||
model = get_domain_model(domain)
|
||||
payload = _model_payload(model, data)
|
||||
record = model(**payload)
|
||||
@@ -117,7 +120,10 @@ class BusinessService:
|
||||
action=f"create:{domain}",
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
request_payload=data,
|
||||
risk_level=(
|
||||
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW
|
||||
),
|
||||
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
@@ -132,21 +138,7 @@ class BusinessService:
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="High-risk domain update requires approval_ticket_id",
|
||||
)
|
||||
if not ApprovalService(self.db).is_approved_for(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
f"update:{domain}",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Approval ticket is not approved for this update",
|
||||
)
|
||||
self._ensure_approved(approval_ticket_id, domain, record_id, f"update:{domain}")
|
||||
model = get_domain_model(domain)
|
||||
record = self.db.get(model, record_id)
|
||||
if record is None:
|
||||
@@ -174,3 +166,26 @@ class BusinessService:
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def _ensure_approved(
|
||||
self,
|
||||
approval_ticket_id: str | None,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
) -> None:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="High-risk domain change requires approval_ticket_id",
|
||||
)
|
||||
if not ApprovalService(self.db).is_approved_for(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
action,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Approval ticket is not approved for this change",
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.events import FeishuEventService
|
||||
from app.modules.feishu.schemas import (
|
||||
@@ -30,21 +30,31 @@ async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dic
|
||||
|
||||
|
||||
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||
def send_text(payload: FeishuTextMessage, db: Session = Depends(get_db)) -> dict:
|
||||
def send_text(
|
||||
payload: FeishuTextMessage,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
result = FeishuService(db).send_text(
|
||||
payload.text,
|
||||
receive_id=payload.receive_id,
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||
|
||||
|
||||
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||
def send_card(payload: FeishuCardMessage, db: Session = Depends(get_db)) -> dict:
|
||||
def send_card(
|
||||
payload: FeishuCardMessage,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
result = FeishuService(db).send_card(
|
||||
payload.card,
|
||||
receive_id=payload.receive_id,
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||
|
||||
@@ -54,12 +64,16 @@ def send_card(payload: FeishuCardMessage, db: Session = Depends(get_db)) -> dict
|
||||
response_model=FeishuCommandResult,
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def preview_command(payload: FeishuCommandRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def preview_command(
|
||||
payload: FeishuCommandRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
"""Preview local Feishu command routing without requiring webhook delivery."""
|
||||
|
||||
return FeishuCommandService(db).handle_text(
|
||||
payload.text,
|
||||
chat_id=payload.chat_id,
|
||||
actor=payload.actor,
|
||||
actor=principal.actor,
|
||||
auto_reply=payload.auto_reply,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,12 @@ class FeishuService:
|
||||
expected = settings.feishu_verification_token
|
||||
header = payload.get("header") or {}
|
||||
token = payload.get("token") or header.get("token")
|
||||
if expected and token and token != expected:
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="FEISHU_VERIFICATION_TOKEN is required",
|
||||
)
|
||||
if not token or token != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Feishu token",
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.legacy_mysql.schemas import (
|
||||
LegacyProjectSyncRequest,
|
||||
LegacyProjectSyncResult,
|
||||
@@ -40,11 +40,15 @@ def default_project_query(limit: int = 100, db: Session = Depends(get_db)) -> di
|
||||
|
||||
|
||||
@router.post("/projects/sync", response_model=LegacyProjectSyncResult)
|
||||
def sync_projects(payload: LegacyProjectSyncRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def sync_projects(
|
||||
payload: LegacyProjectSyncRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return LegacyMySQLService(db).sync_projects(
|
||||
source_query=payload.source_query,
|
||||
field_map=payload.field_map,
|
||||
limit=payload.limit,
|
||||
dry_run=payload.dry_run,
|
||||
actor=payload.actor,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import legacy_engine
|
||||
from app.core.time import utc_now
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
@@ -151,7 +152,11 @@ class LegacyMySQLService:
|
||||
settings = get_settings()
|
||||
external_id = self._value(row, field_map, "external_id", row.get("id"))
|
||||
raw_code = self._value(row, field_map, "code", None)
|
||||
code = str(raw_code) if raw_code else f"{settings.legacy_project_code_prefix}-{external_id}"
|
||||
code = None
|
||||
if raw_code:
|
||||
code = str(raw_code)
|
||||
elif external_id is not None:
|
||||
code = f"{settings.legacy_project_code_prefix}-{external_id}"
|
||||
return {
|
||||
"code": code,
|
||||
"external_id": str(external_id) if external_id is not None else code,
|
||||
@@ -260,11 +265,11 @@ class LegacyMySQLService:
|
||||
"items": items,
|
||||
}
|
||||
sync_run = LegacySyncRun(
|
||||
code=f"SYNC-PROJECTS-{datetime.utcnow():%Y%m%d%H%M%S%f}",
|
||||
code=f"SYNC-PROJECTS-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
domain=BusinessDomain.PROJECTS,
|
||||
source_table="LEGACY_PROJECT_QUERY",
|
||||
status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS,
|
||||
finished_at=datetime.utcnow(),
|
||||
finished_at=utc_now(),
|
||||
created_count=created,
|
||||
updated_count=updated,
|
||||
skipped_count=skipped,
|
||||
|
||||
@@ -101,6 +101,8 @@ class MetricKey(StrEnum):
|
||||
OVER_BUDGET_PROJECTS = "over_budget_projects"
|
||||
OPEN_EVENTS = "open_events"
|
||||
HIGH_EVENTS = "high_events"
|
||||
EXTERNAL_OPEN_EVENTS = "external_open_events"
|
||||
EXTERNAL_HIGH_EVENTS = "external_high_events"
|
||||
EVENTS_BY_TYPE = "events_by_type"
|
||||
EVENTS_BY_LEVEL = "events_by_level"
|
||||
SCORE = "score"
|
||||
|
||||
@@ -3,9 +3,8 @@ from datetime import date
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.reports.schemas import (
|
||||
PushReportRequest,
|
||||
ReportResponse,
|
||||
@@ -33,8 +32,8 @@ def project_lifecycle_report(
|
||||
period_start: date | None = None,
|
||||
period_end: date | None = None,
|
||||
include_ai: bool = False,
|
||||
actor: str = ActorValue.API,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return ReportService(db).project_lifecycle_report(
|
||||
project_code=project_code,
|
||||
@@ -42,7 +41,7 @@ def project_lifecycle_report(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
include_ai=include_ai,
|
||||
actor=actor,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +57,7 @@ def attendance_summary(
|
||||
def generate_work_report(
|
||||
payload: WorkReportGenerateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return ReportService(db).generate_work_report(
|
||||
report_type=payload.report_type,
|
||||
@@ -67,27 +67,35 @@ def generate_work_report(
|
||||
period_start=payload.period_start,
|
||||
period_end=payload.period_end,
|
||||
persist=payload.persist,
|
||||
actor=payload.actor,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/daily-brief/push")
|
||||
def push_daily_brief(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def push_daily_brief(
|
||||
payload: PushReportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
report = ReportService(db).daily_brief()
|
||||
return ReportService(db).push_report(
|
||||
report,
|
||||
payload.receive_id,
|
||||
payload.receive_id_type,
|
||||
payload.actor,
|
||||
principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/project-weekly/push")
|
||||
def push_project_weekly(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def push_project_weekly(
|
||||
payload: PushReportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
report = ReportService(db).project_weekly()
|
||||
return ReportService(db).push_report(
|
||||
report,
|
||||
payload.receive_id,
|
||||
payload.receive_id_type,
|
||||
payload.actor,
|
||||
principal.actor,
|
||||
)
|
||||
|
||||
@@ -6,11 +6,14 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.time import utc_now
|
||||
from app.modules.audit.constants import AuditSource, AuditTargetType
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.business.constants import (
|
||||
ATTENDANCE_ABNORMAL_STATUSES,
|
||||
DONE_STATUSES,
|
||||
GENERATED_RISK_EVENT_TYPES,
|
||||
PROJECT_CLOSED_STATUSES,
|
||||
PENDING_APPROVAL_STATUSES,
|
||||
SUPPLIER_RISK_LEVELS,
|
||||
@@ -69,7 +72,7 @@ def _json_safe(value: Any) -> Any:
|
||||
def _next_code(prefix: str) -> str:
|
||||
"""Build a compact unique code for generated report records."""
|
||||
|
||||
return f"{prefix}-{datetime.utcnow():%Y%m%d%H%M%S%f}"
|
||||
return f"{prefix}-{utc_now():%Y%m%d%H%M%S%f}"
|
||||
|
||||
|
||||
def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float:
|
||||
@@ -228,7 +231,14 @@ class ReportService:
|
||||
task_conditions,
|
||||
risk_conditions,
|
||||
)
|
||||
health = self._lifecycle_health(project_stats, task_stats, risk_stats, supplier_stats)
|
||||
include_global_risk = not (project_code or owner)
|
||||
health = self._lifecycle_health(
|
||||
project_stats,
|
||||
task_stats,
|
||||
risk_stats,
|
||||
supplier_stats,
|
||||
include_global_risk,
|
||||
)
|
||||
attention = self._lifecycle_attention(project_conditions, task_conditions)
|
||||
recommendations = self._lifecycle_recommendations(
|
||||
project_stats,
|
||||
@@ -238,6 +248,7 @@ class ReportService:
|
||||
fund_stats,
|
||||
supplier_stats,
|
||||
risk_stats,
|
||||
include_global_risk,
|
||||
)
|
||||
|
||||
metrics = {
|
||||
@@ -533,12 +544,25 @@ class ReportService:
|
||||
RiskEvent.risk_level == RiskLevel.HIGH,
|
||||
*risk_conditions,
|
||||
)
|
||||
external_open_events = self._count(
|
||||
RiskEvent,
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES),
|
||||
*risk_conditions,
|
||||
)
|
||||
external_high_events = self._count(
|
||||
RiskEvent,
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
RiskEvent.risk_level == RiskLevel.HIGH,
|
||||
RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES),
|
||||
*risk_conditions,
|
||||
)
|
||||
risk_score = (
|
||||
overdue_tasks * 1
|
||||
+ delayed_projects * 3
|
||||
+ over_budget_projects * 4
|
||||
+ open_events * 2
|
||||
+ high_events * 3
|
||||
+ external_open_events * 2
|
||||
+ external_high_events * 3
|
||||
)
|
||||
if risk_score >= 15:
|
||||
level = RiskLevel.HIGH
|
||||
@@ -554,6 +578,8 @@ class ReportService:
|
||||
MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects,
|
||||
MetricKey.OPEN_EVENTS: open_events,
|
||||
MetricKey.HIGH_EVENTS: high_events,
|
||||
MetricKey.EXTERNAL_OPEN_EVENTS: external_open_events,
|
||||
MetricKey.EXTERNAL_HIGH_EVENTS: external_high_events,
|
||||
MetricKey.EVENTS_BY_TYPE: self._group_counts(
|
||||
RiskEvent,
|
||||
RiskEvent.risk_type,
|
||||
@@ -572,16 +598,18 @@ class ReportService:
|
||||
tasks: dict[str, Any],
|
||||
risks: dict[str, Any],
|
||||
suppliers: dict[str, Any],
|
||||
include_global_risk: bool,
|
||||
) -> dict[str, Any]:
|
||||
penalty = (
|
||||
risks[MetricKey.OVERDUE_TASKS] * 3
|
||||
+ risks[MetricKey.DELAYED_PROJECTS] * 8
|
||||
+ risks[MetricKey.OVER_BUDGET_PROJECTS] * 10
|
||||
+ risks[MetricKey.HIGH_EVENTS] * 8
|
||||
+ suppliers[MetricKey.BLACKLISTED] * 10
|
||||
+ risks[MetricKey.EXTERNAL_HIGH_EVENTS] * 8
|
||||
+ max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4
|
||||
+ (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1
|
||||
)
|
||||
if include_global_risk:
|
||||
penalty += suppliers[MetricKey.BLACKLISTED] * 10
|
||||
score = max(0, min(100, round(100 - penalty, 2)))
|
||||
if score >= 80:
|
||||
level = HealthLevel.HEALTHY
|
||||
@@ -641,6 +669,7 @@ class ReportService:
|
||||
funds: dict[str, Any],
|
||||
suppliers: dict[str, Any],
|
||||
risks: dict[str, Any],
|
||||
include_global_risk: bool,
|
||||
) -> list[str]:
|
||||
recommendations: list[str] = []
|
||||
if risks[MetricKey.DELAYED_PROJECTS]:
|
||||
@@ -654,9 +683,9 @@ class ReportService:
|
||||
or expenses[MetricKey.PENDING_APPROVAL]
|
||||
):
|
||||
recommendations.append(ReportText.RECOMMEND_APPROVALS)
|
||||
if funds[MetricKey.RISK_ACCOUNTS]:
|
||||
if include_global_risk and funds[MetricKey.RISK_ACCOUNTS]:
|
||||
recommendations.append(ReportText.RECOMMEND_FUNDS)
|
||||
if suppliers[MetricKey.RISKY]:
|
||||
if include_global_risk and suppliers[MetricKey.RISKY]:
|
||||
recommendations.append(ReportText.RECOMMEND_SUPPLIERS)
|
||||
if not recommendations:
|
||||
recommendations.append(ReportText.RECOMMEND_STABLE)
|
||||
@@ -835,9 +864,9 @@ class ReportService:
|
||||
AuditService(self.db).log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="reports",
|
||||
source=AuditSource.REPORTS,
|
||||
action=f"generate_{report_type}_report",
|
||||
target_type="work-reports",
|
||||
target_type=AuditTargetType.WORK_REPORTS,
|
||||
target_id=str(record.id),
|
||||
response_payload=record_data,
|
||||
)
|
||||
@@ -871,17 +900,31 @@ class ReportService:
|
||||
WorkTask.due_date >= start,
|
||||
WorkTask.due_date <= end,
|
||||
]
|
||||
procurement_filters = [Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES)]
|
||||
expense_filters = [Expense.approval_status.in_(PENDING_APPROVAL_STATUSES)]
|
||||
start_at = datetime.combine(start, datetime.min.time())
|
||||
end_at = datetime.combine(end, datetime.max.time())
|
||||
project_filters = []
|
||||
risk_filters = [RiskEvent.status == StatusValue.OPEN]
|
||||
procurement_filters = [
|
||||
Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES),
|
||||
Procurement.created_at >= start_at,
|
||||
Procurement.created_at <= end_at,
|
||||
]
|
||||
expense_filters = [
|
||||
Expense.approval_status.in_(PENDING_APPROVAL_STATUSES),
|
||||
Expense.created_at >= start_at,
|
||||
Expense.created_at <= end_at,
|
||||
]
|
||||
attendance_filters = [
|
||||
AttendanceRecord.work_date >= start,
|
||||
AttendanceRecord.work_date <= end,
|
||||
]
|
||||
if project_code:
|
||||
project_filters.append(Project.code == project_code)
|
||||
task_filters.append(WorkTask.project_code == project_code)
|
||||
procurement_filters.append(Procurement.project_code == project_code)
|
||||
expense_filters.append(Expense.project_code == project_code)
|
||||
attendance_filters.append(AttendanceRecord.project_code == project_code)
|
||||
risk_filters.append(RiskEvent.project_code == project_code)
|
||||
if department:
|
||||
expense_filters.append(Expense.department == department)
|
||||
attendance_filters.append(AttendanceRecord.department == department)
|
||||
@@ -894,10 +937,11 @@ class ReportService:
|
||||
*task_filters,
|
||||
)
|
||||
return {
|
||||
"projects_total": self._count(Project),
|
||||
"projects_total": self._count(Project, *project_filters),
|
||||
"active_projects": self._count(
|
||||
Project,
|
||||
Project.status.notin_(PROJECT_CLOSED_STATUSES),
|
||||
*project_filters,
|
||||
),
|
||||
"tasks_total": self._count(WorkTask, *task_filters),
|
||||
"tasks_completed": completed_tasks,
|
||||
@@ -905,10 +949,7 @@ class ReportService:
|
||||
"procurements_pending": self._count(Procurement, *procurement_filters),
|
||||
"expenses_pending": self._count(Expense, *expense_filters),
|
||||
"attendance_total": self._count(AttendanceRecord, *attendance_filters),
|
||||
"open_risk_events": self._count(
|
||||
RiskEvent,
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
),
|
||||
"open_risk_events": self._count(RiskEvent, *risk_filters),
|
||||
}
|
||||
|
||||
def _work_report_lines(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.risk.service import RiskService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
@@ -49,5 +48,8 @@ def risk_events(
|
||||
|
||||
|
||||
@router.post("/events/generate")
|
||||
def generate_risk_events(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict:
|
||||
return RiskService(db).generate_events(actor=actor)
|
||||
def generate_risk_events(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return RiskService(db).generate_events(actor=principal.actor)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.time import utc_now
|
||||
from app.modules.audit.constants import (
|
||||
AuditAction,
|
||||
AuditRiskLevel,
|
||||
@@ -17,6 +18,7 @@ from app.modules.audit.service import AuditService
|
||||
from app.modules.business.constants import (
|
||||
CLOSED_RISK_STATUSES,
|
||||
DONE_STATUSES,
|
||||
GENERATED_RISK_EVENT_TYPES,
|
||||
PROJECT_CLOSED_STATUSES,
|
||||
SUPPLIER_RISK_LEVELS,
|
||||
BusinessDomain,
|
||||
@@ -90,13 +92,18 @@ class RiskService:
|
||||
fund_risks = self.fund_risks()
|
||||
supplier_risks = self.supplier_risks()
|
||||
open_events = self.list_events(status_filter=StatusValue.OPEN)
|
||||
external_open_events = [
|
||||
item
|
||||
for item in open_events
|
||||
if item.get("risk_type") not in GENERATED_RISK_EVENT_TYPES
|
||||
]
|
||||
risk_score = (
|
||||
len(overdue_tasks) * 1
|
||||
+ len(delayed_projects) * 3
|
||||
+ len(over_budget_projects) * 4
|
||||
+ len(fund_risks) * 5
|
||||
+ len(supplier_risks) * 3
|
||||
+ len(open_events) * 2
|
||||
+ len(external_open_events) * 2
|
||||
)
|
||||
if risk_score >= 15:
|
||||
level = RiskLevel.HIGH
|
||||
@@ -192,7 +199,7 @@ class RiskService:
|
||||
"project_code": task.project_code,
|
||||
"owner": task.owner,
|
||||
"due_date": task.due_date,
|
||||
"detected_at": datetime.utcnow(),
|
||||
"detected_at": utc_now(),
|
||||
"description": "任务已超过截止日期且未完成。",
|
||||
"mitigation": (
|
||||
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
|
||||
@@ -223,7 +230,7 @@ class RiskService:
|
||||
"project_code": project.code,
|
||||
"owner": project.owner,
|
||||
"due_date": project.due_date,
|
||||
"detected_at": datetime.utcnow(),
|
||||
"detected_at": utc_now(),
|
||||
"description": "项目已超过计划截止日期且未进入完成状态。",
|
||||
"mitigation": (
|
||||
"请项目负责人提交延期原因、资源需求和纠偏计划。"
|
||||
@@ -252,7 +259,7 @@ class RiskService:
|
||||
"project_code": project.code,
|
||||
"owner": project.owner,
|
||||
"due_date": project.due_date,
|
||||
"detected_at": datetime.utcnow(),
|
||||
"detected_at": utc_now(),
|
||||
"description": "项目实际成本已超过预算。",
|
||||
"mitigation": (
|
||||
"请复核预算科目、冻结非必要采购并补充审批依据。"
|
||||
@@ -276,7 +283,7 @@ class RiskService:
|
||||
"source_domain": BusinessDomain.FUND_ACCOUNTS,
|
||||
"source_record_id": str(account.id),
|
||||
"owner": None,
|
||||
"detected_at": datetime.utcnow(),
|
||||
"detected_at": utc_now(),
|
||||
"description": "账户当前余额低于设置的安全线。",
|
||||
"mitigation": (
|
||||
"请财务确认收付款计划,"
|
||||
@@ -309,7 +316,7 @@ class RiskService:
|
||||
"source_domain": BusinessDomain.SUPPLIERS,
|
||||
"source_record_id": str(supplier.id),
|
||||
"owner": supplier.contact,
|
||||
"detected_at": datetime.utcnow(),
|
||||
"detected_at": utc_now(),
|
||||
"description": "供应商风险等级或黑名单状态需要关注。",
|
||||
"mitigation": (
|
||||
"请采购负责人复核供应商准入、履约和替代方案。"
|
||||
|
||||
Reference in New Issue
Block a user