"""HTTP surface for 周期性自我检查 (periodic self-check). Mounts under ``/api/v1/auditor/self-checks``. RBAC mirrors the rest of the auditor domain: * ``can_view_security`` — read endpoints (auditor + global_admin) * ``can_manage_security`` — catalog edits / record decisions / evidence Evidence files go to S3 via a conditional presigned PUT scoped to this domain (image + pdf only), so the generic employee ``/api/v1/uploads`` surface stays narrow. Auditors download via a freshly-minted presigned GET (JSON, not a 302 — same contract as the CLI distribution download). """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user, get_real_ip from app.core.exceptions import ForbiddenError from app.core.exporting import xlsx_export_response from app.core.rbac import can_manage_security, can_view_security from app.core.response import success_response from app.domains.auditor import self_check_service as svc from app.domains.auditor.self_check_schemas import ( AttachmentPresignRequest, AttachmentRegisterRequest, CategoryCreateRequest, CategoryUpdateRequest, RecordCompleteRequest, RecordManualCreateRequest, RecordRemarkRequest, RecordSkipRequest, ) router = APIRouter(prefix="/api/v1/auditor/self-checks", tags=["auditor"]) def _require_view(user: AuthUser) -> None: if not can_view_security(user.role_names): raise ForbiddenError( "auditor or global_admin role required to view security data", ) def _require_manage(user: AuthUser) -> None: if not can_manage_security(user.role_names): raise ForbiddenError( "auditor or global_admin role required to mutate security data", ) async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str | None, detail: dict | None = None, ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type="auditor_self_check", resource_id=resource_id, detail=detail or {}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) # ════════════════════════════════════════════════════════════════════ # Overview # ════════════════════════════════════════════════════════════════════ @router.get("/overview") async def get_overview( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response(await svc.get_overview(db), request=request) # ════════════════════════════════════════════════════════════════════ # Category catalog # ════════════════════════════════════════════════════════════════════ @router.get("/categories") async def list_categories( request: Request, include_disabled: bool = Query(True), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) items = await svc.list_categories(db, include_disabled=include_disabled) return success_response({"items": items}, request=request) @router.post("/categories") async def create_category( request: Request, payload: CategoryCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) cat = await svc.create_category(db, payload=payload.model_dump()) await _audit( db, request, user, action="auditor.self_check.category.create", resource_id=str(cat.id), detail={"key": cat.key}, ) await db.commit() return success_response(svc.category_view(cat), request=request) @router.patch("/categories/{category_id}") async def update_category( request: Request, category_id: uuid.UUID, payload: CategoryUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) cat = await svc.update_category( db, category_id, payload=payload.model_dump(exclude_unset=True), ) await _audit( db, request, user, action="auditor.self_check.category.update", resource_id=str(cat.id), ) await db.commit() return success_response(svc.category_view(cat), request=request) # ════════════════════════════════════════════════════════════════════ # Records # ════════════════════════════════════════════════════════════════════ @router.get("/records") async def list_records( request: Request, category_id: uuid.UUID | None = Query(None), status: str | None = Query(None), period_year: int | None = Query(None), overdue_only: bool = Query(False), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) result = await svc.list_records( db, category_id=category_id, status=status, period_year=period_year, overdue_only=overdue_only, page=page, page_size=page_size, ) return success_response(result, request=request) @router.get("/records/export") async def export_records( request: Request, category_id: uuid.UUID | None = Query(None), status: str | None = Query(None), period_year: int | None = Query(None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows, total = await svc.export_rows( db, category_id=category_id, status=status, period_year=period_year, ) return await xlsx_export_response( db, request, user, columns=svc.EXPORT_COLUMNS, rows=rows, total_matched=total, filename_stem="periodic_self_check", sheet_title="周期性自我检查", audit_action="auditor.self_check.export", audit_resource_type="auditor_self_check", filters={ "category_id": str(category_id) if category_id else None, "status": status, "period_year": period_year, }, ) @router.post("/records") async def create_record( request: Request, payload: RecordManualCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) rec = await svc.create_record_manual( db, category_id=payload.category_id, year=payload.period_year, label=payload.period_label, ) await _audit( db, request, user, action="auditor.self_check.record.create", resource_id=str(rec.id), detail={"period": f"{rec.period_year}-{rec.period_label}"}, ) await db.commit() return success_response({"id": str(rec.id)}, request=request) @router.get("/records/{record_id}") async def get_record( request: Request, record_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) view = await svc.get_record_detail(db, record_id) return success_response(view, request=request) @router.post("/records/{record_id}/complete") async def complete_record( request: Request, record_id: uuid.UUID, payload: RecordCompleteRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) rec = await svc.complete_record( db, record_id, checker_person_id=user.id, outcome=payload.outcome, remark=payload.remark, ) await _audit( db, request, user, action="auditor.self_check.record.complete", resource_id=str(rec.id), detail={"outcome": rec.outcome}, ) await db.commit() return success_response({"id": str(rec.id), "status": rec.status}, request=request) @router.post("/records/{record_id}/skip") async def skip_record( request: Request, record_id: uuid.UUID, payload: RecordSkipRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) rec = await svc.skip_record( db, record_id, checker_person_id=user.id, remark=payload.remark, ) await _audit( db, request, user, action="auditor.self_check.record.skip", resource_id=str(rec.id), ) await db.commit() return success_response({"id": str(rec.id), "status": rec.status}, request=request) @router.post("/records/{record_id}/reopen") async def reopen_record( request: Request, record_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) rec = await svc.reopen_record(db, record_id) await _audit( db, request, user, action="auditor.self_check.record.reopen", resource_id=str(rec.id), ) await db.commit() return success_response({"id": str(rec.id), "status": rec.status}, request=request) @router.patch("/records/{record_id}/remark") async def update_remark( request: Request, record_id: uuid.UUID, payload: RecordRemarkRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) rec = await svc.update_remark(db, record_id, remark=payload.remark) await _audit( db, request, user, action="auditor.self_check.record.remark", resource_id=str(rec.id), ) await db.commit() return success_response({"id": str(rec.id)}, request=request) @router.post("/open-due") async def open_due( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Manually run the period opener (the cron does this automatically).""" _require_manage(user) result = await svc.open_due_records(db) await _audit( db, request, user, action="auditor.self_check.open_due", resource_id=None, detail=result, ) await db.commit() return success_response(result, request=request) # ════════════════════════════════════════════════════════════════════ # Evidence attachments # ════════════════════════════════════════════════════════════════════ @router.post("/records/{record_id}/attachments/presign") async def presign_attachment( request: Request, record_id: uuid.UUID, payload: AttachmentPresignRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) result = await svc.presign_evidence_upload( db, record_id=record_id, person_id=user.id, filename=payload.filename, content_type=payload.content_type, size_bytes=payload.size_bytes, checksum_sha256=payload.checksum_sha256, ) return success_response(result, request=request) @router.post("/records/{record_id}/attachments") async def register_attachment( request: Request, record_id: uuid.UUID, payload: AttachmentRegisterRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) att = await svc.register_attachment( db, record_id=record_id, object_key=payload.object_key, filename=payload.filename, content_type=payload.content_type, # ``payload.size_bytes`` is deliberately NOT forwarded (#2247) — # the service HEADs the object for the authoritative count. uploaded_by_person_id=user.id, ) await _audit( db, request, user, action="auditor.self_check.attachment.add", resource_id=str(record_id), detail={"attachment_id": str(att.id)}, ) await db.commit() return success_response({"id": str(att.id)}, request=request) @router.get("/attachments/{attachment_id}/download") async def download_attachment( request: Request, attachment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) url, att = await svc.presign_attachment_download(db, attachment_id) # See the vendor-assessment twin: minting the presigned URL is the # data-egress event and must leave the same trace the xlsx export of # this record already leaves. await _audit( db, request, user, action="auditor.self_check.attachment.download", resource_id=str(att.record_id), detail={"attachment_id": str(att.id), "filename": att.filename}, ) await db.commit() return success_response({"url": url}, request=request) @router.delete("/attachments/{attachment_id}") async def delete_attachment( request: Request, attachment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) record_id = await svc.delete_attachment(db, attachment_id) await _audit( db, request, user, action="auditor.self_check.attachment.delete", resource_id=str(record_id), detail={"attachment_id": str(attachment_id)}, ) await db.commit() return success_response({"record_id": str(record_id)}, request=request)