Files
ctms/backend/app/api/v1/fees_contracts.py
T
Cheng Zhou 0cc87210af 权限系统:完成接口级权限系统第6阶段(测试与文档)
## 主要完成内容

### 1. 接口级权限系统实现
- 新增 ApiEndpointPermission 模型:存储接口级权限配置
- 新增 ApiEndpointRegistry 模型:注册系统中所有API端点
- 实现权限检查优先级:接口级 > 模块级(向后兼容)
- 支持细粒度权限控制(METHOD:/path 格式)

### 2. 权限配置系统
- 创建 api_permissions.py:集中管理接口权限配置
- 定义 API_ENDPOINT_PERMISSIONS:所有端点的权限映射
- 定义 MODULE_TO_ENDPOINTS:模块到接口的映射(向后兼容)
- 支持默认角色配置和权限继承

### 3. 权限检查依赖注入
- 新增 require_api_permission():基于接口的权限检查
- 新增 @register_api_endpoint 装饰器:端点元数据注册
- 集成 FastAPI 依赖注入系统
- 支持权限拒绝时返回 403 Forbidden

### 4. API端点迁移(第1批)
- 迁移 subjects 模块:5个端点
- 迁移 risk_issues 模块:3个端点
- 迁移 fees 模块:8个端点
- 迁移 finance_contracts 模块:5个端点
- 共计 21 个端点完成迁移

### 5. 权限管理API
- GET /studies/{study_id}/api-permissions:获取权限矩阵
- PUT /studies/{study_id}/api-permissions:更新权限矩阵
- 支持权限配置的查询和修改

### 6. 测试与验证
- 单元测试:12 个测试用例,全部通过
- 集成测试:11 个权限管理API测试,全部通过
- 端点测试:22 个已迁移端点测试,全部通过
- 代码覆盖率:87%(超过 80% 目标)
- 总计:45 个测试用例,全部通过

### 7. 文档
- TESTING_SUMMARY.md:详细的测试结果总结
- IMPLEMENTATION_SUMMARY.md:实现细节文档
- TESTING_GUIDE.md:测试指南

## 技术亮点

1. **向后兼容性**:保留模块级权限,接口级权限优先
2. **灵活的权限配置**:支持默认角色和自定义权限
3. **细粒度控制**:支持跨模块数据访问权限
4. **完整的测试覆盖**:单元测试、集成测试、端点测试
5. **清晰的权限检查流程**:接口级 → 模块级 → 拒绝

## 下一步工作

- [ ] 第7阶段:迁移第2批模块(members, sites)
- [ ] 第8阶段:迁移第3批模块
- [ ] 第9阶段:安全审计
- [ ] 第10阶段:性能测试
- [ ] 第11阶段:文档更新

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-13 15:18:02 +08:00

403 lines
17 KiB
Python

import uuid
from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud
from app.crud import member as member_crud
from app.crud import site as site_crud
from app.crud import user as user_crud
from app.crud import study as study_crud
from app.schemas.contract_fee import ContractFeeCreate, ContractFeeDetail, ContractFeeListItem, ContractFeeRead, ContractFeeUpdate
from app.schemas.contract_fee_payment import (
ContractFeePaymentCreate,
ContractFeePaymentRead,
ContractFeePaymentUpdate,
)
from app.schemas.fee_common import FeeApiResponse
from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.user import UserDisplay
from app.models.attachment import Attachment
router = APIRouter()
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, endpoint_key: str):
study = await study_crud.get(db, project_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_api_permission(db, project_id, membership.role_in_study, endpoint_key)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足")
return membership
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _to_decimal(value: Any) -> Decimal:
if isinstance(value, Decimal):
return value
if value is None:
return Decimal("0")
try:
return Decimal(str(value))
except Exception:
return Decimal("0")
def _validate_payment_rules(data: ContractFeePaymentCreate | ContractFeePaymentUpdate):
if data.is_verified is True and data.is_paid is False:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="核销需先打款")
if data.is_paid and not data.paid_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已打款需填写打款日期")
if data.is_verified and not data.verified_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已核销需填写核销日期")
@router.get(
"/contracts",
response_model=FeeApiResponse[list[ContractFeeListItem]],
dependencies=[Depends(get_current_user)],
)
async def list_contract_fees(
project_id: uuid.UUID = Query(..., alias="projectId"),
center_id: uuid.UUID | None = Query(None, alias="centerId"),
q: str | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[ContractFeeListItem]]:
await _ensure_project_access(db, project_id, current_user, "GET:/fees/contracts")
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await contract_fee_crud.list_contract_fees(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
q=q,
)
items: list[ContractFeeListItem] = []
for contract, center_name, paid_total, verified_total, last_paid_date, last_verified_date in rows:
paid_total_decimal = _to_decimal(paid_total)
verified_total_decimal = _to_decimal(verified_total)
contract_amount_decimal = _to_decimal(contract.contract_amount)
unpaid_balance = contract_amount_decimal - paid_total_decimal
unverified_balance = paid_total_decimal - verified_total_decimal
items.append(
ContractFeeListItem(
id=contract.id,
project_id=contract.project_id,
center_id=contract.center_id,
contract_amount=contract_amount_decimal,
contract_cases=contract.contract_cases,
actual_cases=contract.actual_cases,
settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None,
final_payment_amount=_to_decimal(contract.final_payment_amount) if contract.final_payment_amount else None,
created_at=contract.created_at,
updated_at=contract.updated_at,
center_name=center_name or "",
paid_total=paid_total_decimal,
verified_total=verified_total_decimal,
unpaid_balance=unpaid_balance,
unverified_balance=unverified_balance,
last_paid_date=last_paid_date,
last_verified_date=last_verified_date,
)
)
return FeeApiResponse(data=items, meta={"total": len(items)})
@router.get(
"/contracts/{contract_id}",
response_model=FeeApiResponse[ContractFeeDetail],
dependencies=[Depends(get_current_user)],
)
async def get_contract_fee(
contract_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeeDetail]:
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "GET:/fees/contracts/{id}")
cra_scope = await get_cra_site_scope(db, contract.project_id, current_user)
if cra_scope and contract.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
payments = await payment_crud.list_payments(db, contract.id)
attachment_types = ["contract_fee_contract", "contract_fee_voucher", "contract_fee_invoice"]
result = await db.execute(
select(Attachment).where(
Attachment.entity_id == contract.id,
Attachment.entity_type.in_(attachment_types),
Attachment.is_deleted.is_(False),
)
)
attachments = result.scalars().all()
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
users_map = await user_crud.get_users_by_ids(db, user_ids)
attachments_map: dict[str, list[FeeAttachmentRead]] = {
"contract": [],
"voucher": [],
"invoice": [],
}
for attachment in attachments:
key = attachment.entity_type.replace("contract_fee_", "", 1)
attachments_map.setdefault(key, [])
user = users_map.get(attachment.uploaded_by)
attachments_map[key].append(
FeeAttachmentRead(
id=attachment.id,
entity_type="contract_fee",
entity_id=attachment.entity_id,
file_type=key,
filename=attachment.filename,
mime_type=attachment.content_type,
size=attachment.file_size,
storage_key=attachment.file_path,
url=None,
uploaded_by_id=attachment.uploaded_by,
uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_at=attachment.uploaded_at,
)
)
payment_reads = [ContractFeePaymentRead.model_validate(payment) for payment in payments]
detail = ContractFeeDetail(
id=contract.id,
project_id=contract.project_id,
center_id=contract.center_id,
contract_amount=_to_decimal(contract.contract_amount),
contract_cases=contract.contract_cases,
actual_cases=contract.actual_cases,
settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None,
final_payment_amount=_to_decimal(contract.final_payment_amount) if contract.final_payment_amount else None,
created_at=contract.created_at,
updated_at=contract.updated_at,
payments=payment_reads,
attachments=attachments_map,
)
return FeeApiResponse(data=detail)
@router.post(
"/contracts",
response_model=FeeApiResponse[ContractFeeRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def create_contract_fee(
contract_in: ContractFeeCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeeRead]:
await _ensure_project_access(db, contract_in.project_id, current_user, "POST:/fees/contracts")
existing = await contract_fee_crud.get_contract_fee_by_project_center(
db, contract_in.project_id, contract_in.center_id
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
site = await site_crud.get_site(db, contract_in.center_id)
if not site or site.study_id != contract_in.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
contract = await contract_fee_crud.create_contract_fee(db, contract_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
entity_type="contract_fee",
entity_id=contract.id,
action="CREATE_CONTRACT_FEE",
detail="合同费用已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@router.patch(
"/contracts/{contract_id}",
response_model=FeeApiResponse[ContractFeeRead],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_contract_fee(
contract_id: uuid.UUID,
contract_in: ContractFeeUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeeRead]:
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "PATCH:/fees/contracts/{id}")
await _ensure_center_active(db, contract.project_id, contract.center_id)
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
entity_type="contract_fee",
entity_id=contract_id,
action="UPDATE_CONTRACT_FEE",
detail="合同费用已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@router.delete(
"/contracts/{contract_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_contract_fee(
contract_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "DELETE:/fees/contracts/{id}")
await _ensure_center_active(db, contract.project_id, contract.center_id)
await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action(
db,
study_id=contract.project_id,
entity_type="contract_fee",
entity_id=contract_id,
action="DELETE_CONTRACT_FEE",
detail="合同费用已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
@router.post(
"/contracts/{contract_id}/payments",
response_model=FeeApiResponse[ContractFeePaymentRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def create_contract_payment(
contract_id: uuid.UUID,
payment_in: ContractFeePaymentCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeePaymentRead]:
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "POST:/fees/contracts/{id}/payments")
_validate_payment_rules(payment_in)
payment = await payment_crud.create_payment(db, contract_id, payment_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
entity_type="contract_fee_payment",
entity_id=payment.id,
action="CREATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@router.patch(
"/payments/{payment_id}",
response_model=FeeApiResponse[ContractFeePaymentRead],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_contract_payment(
payment_id: uuid.UUID,
payment_in: ContractFeePaymentUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeePaymentRead]:
payment = await payment_crud.get_payment(db, payment_id)
if not payment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "PATCH:/fees/payments/{id}")
merged_payment = ContractFeePaymentCreate(
amount=payment_in.amount if payment_in.amount is not None else payment.amount,
paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date,
verified_date=payment_in.verified_date if payment_in.verified_date is not None else payment.verified_date,
is_paid=payment_in.is_paid if payment_in.is_paid is not None else payment.is_paid,
is_verified=payment_in.is_verified if payment_in.is_verified is not None else payment.is_verified,
remark=payment_in.remark if payment_in.remark is not None else payment.remark,
)
_validate_payment_rules(merged_payment)
payment = await payment_crud.update_payment(db, payment, payment_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="UPDATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@router.delete(
"/payments/{payment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_contract_payment(
payment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
payment = await payment_crud.get_payment(db, payment_id)
if not payment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "DELETE:/fees/payments/{id}")
await payment_crud.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action(
db,
study_id=contract.project_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="DELETE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)