video-compliance-ai/backend/app/api/risk_exceptions.py
Your Name e4959d584f feat: 完善代理商端业务逻辑与前后端框架
主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:27:31 +08:00

227 lines
6.8 KiB
Python

"""
特例审批 API
创建、查询、审批特例记录
"""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.tenant import Tenant
from app.models.risk_exception import (
RiskException,
RiskTargetType as DBRiskTargetType,
RiskExceptionStatus as DBRiskExceptionStatus,
)
from app.schemas.review import (
RiskExceptionCreateRequest,
RiskExceptionRecord,
RiskExceptionStatus,
RiskExceptionDecisionRequest,
RiskTargetType,
)
router = APIRouter(prefix="/risk-exceptions", tags=["risk-exceptions"])
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
"""确保租户存在,不存在则自动创建"""
result = await db.execute(
select(Tenant).where(Tenant.id == tenant_id)
)
tenant = result.scalar_one_or_none()
if not tenant:
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
db.add(tenant)
await db.flush()
return tenant
def _exception_to_response(record: RiskException) -> RiskExceptionRecord:
"""将数据库模型转换为响应模型"""
return RiskExceptionRecord(
record_id=record.id,
applicant_id=record.applicant_id,
apply_time=record.apply_time,
target_type=RiskTargetType(record.target_type.value),
target_id=record.target_id,
risk_rule_id=record.risk_rule_id,
status=RiskExceptionStatus(record.status.value),
valid_start_time=record.valid_start_time,
valid_end_time=record.valid_end_time,
reason_category=record.reason_category,
justification=record.justification,
attachment_url=record.attachment_url,
current_approver_id=record.current_approver_id,
approval_chain_log=record.approval_chain_log or [],
auto_rejected=record.auto_rejected,
rejection_reason=record.rejection_reason,
last_status_at=record.last_status_at,
)
@router.post("", response_model=RiskExceptionRecord, status_code=status.HTTP_201_CREATED)
async def create_exception(
request: RiskExceptionCreateRequest,
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
db: AsyncSession = Depends(get_db),
) -> RiskExceptionRecord:
"""创建特例申请"""
# 确保租户存在
await _ensure_tenant_exists(x_tenant_id, db)
record_id = f"exc-{uuid.uuid4().hex[:12]}"
now = datetime.now(timezone.utc)
record = RiskException(
id=record_id,
tenant_id=x_tenant_id,
applicant_id=request.applicant_id,
apply_time=now,
target_type=DBRiskTargetType(request.target_type.value),
target_id=request.target_id,
risk_rule_id=request.risk_rule_id,
status=DBRiskExceptionStatus.PENDING,
valid_start_time=request.valid_start_time,
valid_end_time=request.valid_end_time,
reason_category=request.reason_category,
justification=request.justification,
attachment_url=request.attachment_url,
current_approver_id=request.current_approver_id,
approval_chain_log=[],
auto_rejected=False,
rejection_reason=None,
last_status_at=now,
)
db.add(record)
await db.flush()
await db.refresh(record)
return _exception_to_response(record)
@router.get("/{record_id}", response_model=RiskExceptionRecord)
async def get_exception(
record_id: str,
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
db: AsyncSession = Depends(get_db),
) -> RiskExceptionRecord:
"""查询特例记录"""
result = await db.execute(
select(RiskException).where(
and_(
RiskException.id == record_id,
RiskException.tenant_id == x_tenant_id,
)
)
)
record = result.scalar_one_or_none()
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"特例记录不存在: {record_id}",
)
return _exception_to_response(record)
@router.post("/{record_id}/approve", response_model=RiskExceptionRecord)
async def approve_exception(
record_id: str,
request: RiskExceptionDecisionRequest,
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
db: AsyncSession = Depends(get_db),
) -> RiskExceptionRecord:
"""审批通过"""
result = await db.execute(
select(RiskException).where(
and_(
RiskException.id == record_id,
RiskException.tenant_id == x_tenant_id,
)
)
)
record = result.scalar_one_or_none()
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"特例记录不存在: {record_id}",
)
now = datetime.now(timezone.utc)
record.status = DBRiskExceptionStatus.APPROVED
record.last_status_at = now
# 更新审批日志
approval_log = record.approval_chain_log or []
approval_log.append({
"approver_id": request.approver_id,
"action": "approve",
"comment": request.comment,
"timestamp": now.isoformat(),
})
record.approval_chain_log = approval_log
await db.flush()
await db.refresh(record)
return _exception_to_response(record)
@router.post("/{record_id}/reject", response_model=RiskExceptionRecord)
async def reject_exception(
record_id: str,
request: RiskExceptionDecisionRequest,
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
db: AsyncSession = Depends(get_db),
) -> RiskExceptionRecord:
"""驳回申请"""
result = await db.execute(
select(RiskException).where(
and_(
RiskException.id == record_id,
RiskException.tenant_id == x_tenant_id,
)
)
)
record = result.scalar_one_or_none()
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"特例记录不存在: {record_id}",
)
# 驳回必须填写原因
if not request.comment:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="驳回必须填写原因",
)
now = datetime.now(timezone.utc)
record.status = DBRiskExceptionStatus.REJECTED
record.rejection_reason = request.comment
record.last_status_at = now
# 更新审批日志
approval_log = record.approval_chain_log or []
approval_log.append({
"approver_id": request.approver_id,
"action": "reject",
"comment": request.comment,
"timestamp": now.isoformat(),
})
record.approval_chain_log = approval_log
await db.flush()
await db.refresh(record)
return _exception_to_response(record)