feat: 添加审批系统和遗留查询功能支持

- 添加审批系统,包括审批请求模型、服务和路由,支持创建、批准和拒绝操作
- 实现审批API密钥验证机制,区分普通API和审批API访问权限
- 添加Alembic数据库迁移支持,更新初始schema版本并添加降级保护
- 配置遗留MySQL查询白名单机制,支持命名查询和参数化查询
- 更新业务服务以集成审批流程,高风险操作需要审批票证
- 调整安全认证使用常量定义的HTTP头,增强安全性比较
- 优化.gitignore配置,添加日志目录排除和文档文件包含规则
- 更新Dockerfile添加alembic依赖包,修复OpenClaw适配器错误处理
```
This commit is contained in:
2026-07-06 09:39:25 +08:00
parent ae5990eaef
commit e3a4a6d426
28 changed files with 536 additions and 86 deletions

View File

@@ -1,12 +1,19 @@
import json
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Any
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.constants import (
ApprovalActionValue,
ApprovalErrorDetail,
ApprovalStatus,
)
from app.modules.approvals.models import ApprovalRequest
from app.modules.approvals.schemas import ApprovalCreate
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
@@ -61,7 +68,7 @@ class ApprovalService:
if ticket is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Approval ticket not found",
detail=ApprovalErrorDetail.NOT_FOUND,
)
return ticket
@@ -74,7 +81,15 @@ class ApprovalService:
) -> ApprovalRequest:
ticket = self.get_by_ticket(ticket_id)
if ticket.status != ApprovalStatus.PENDING:
raise HTTPException(status_code=409, detail="Approval ticket already decided")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=ApprovalErrorDetail.ALREADY_DECIDED,
)
if approved and approver == ticket.applicant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ApprovalErrorDetail.SELF_APPROVAL,
)
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
ticket.approver = approver
ticket.decision_comment = comment
@@ -95,6 +110,33 @@ class ApprovalService:
)
return ticket
def consume_for(
self,
ticket_id: str,
domain: str,
record_id: str | int | None,
action: str,
payload: dict[str, Any],
actor: str,
) -> ApprovalRequest:
ticket = self.get_by_ticket(ticket_id)
if not self._is_ticket_scope_valid(ticket, domain, record_id, action):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ApprovalErrorDetail.NOT_APPROVED,
)
if not _payload_matches(ticket.payload, payload):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
)
ticket.status = ApprovalStatus.USED
ticket.used_by = actor
ticket.used_at = utc_now()
if record_id is not None and not ticket.record_id:
ticket.record_id = str(record_id)
return ticket
def is_approved_for(
self,
ticket_id: str,
@@ -103,6 +145,15 @@ class ApprovalService:
action: str,
) -> bool:
ticket = self.get_by_ticket(ticket_id)
return self._is_ticket_scope_valid(ticket, domain, record_id, action)
@staticmethod
def _is_ticket_scope_valid(
ticket: ApprovalRequest,
domain: str,
record_id: str | int | None,
action: str,
) -> bool:
if ticket.status != ApprovalStatus.APPROVED:
return False
if ticket.domain != domain:
@@ -115,5 +166,28 @@ class ApprovalService:
action,
ApprovalActionValue.UPDATE,
f"{ApprovalActionValue.UPDATE}:{domain}",
ApprovalActionValue.WILDCARD,
}
def _payload_matches(approved_payload: str | None, requested_payload: dict[str, Any]) -> bool:
try:
parsed_payload = json.loads(approved_payload or "{}")
except json.JSONDecodeError:
parsed_payload = {}
return _canonical_payload(parsed_payload) == _canonical_payload(requested_payload)
def _canonical_payload(value: Any) -> str:
return json.dumps(_json_safe(value), ensure_ascii=False, sort_keys=True, default=str)
def _json_safe(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
return value