Files
bid-doctor/server/src/bid_doctor/audit/service.py
YANG JIANKUAN e7fb797925
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(report): 检查报告 PDF 导出和归档;项目状态流转(递交 / 归档)
- report 模块:导出 → worker 用 Gotenberg 把 HTML 转成 A4 PDF → 按项目数据密钥加密存储 → report
  (不可改、存哈希);report_job 记生成过程(迁移 0009),完成推 report.ready、发通知
- 报告首页:项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;
  问题明细带两侧出处和处理人;附录为需人工核对的勾选结果;模板文字不出现「通过 / 合格 / 保证」
- 前端「报告」标签页:导出选项、内嵌预览、历次报告下载;工作台「导出检查报告」直达
- 项目状态流转 POST /projects/{id}/transitions:致命未处理也能递交(确认框说明),
  递交后只读,撤销归档回到归档前的状态,都写审计
- 通知补上检查、报告的标题

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:51:43 +08:00

75 lines
2.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""审计:和业务修改在同一个事务里写入,写失败整个操作就失败(架构 §3.10)。"""
from __future__ import annotations
import json
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit.models import AuditEvent
from bid_doctor.core.crypto import sha256
from bid_doctor.core.db import TenantContext
from bid_doctor.core.ids import uuid7
async def record(
session: AsyncSession,
ctx: TenantContext,
action: str,
object_type: str,
object_id: UUID | None,
*,
project_id: UUID | None = None,
before: dict[str, Any] | None = None,
after: dict[str, Any] | None = None,
actor_kind: str = "user",
) -> None:
digest = (
sha256(json.dumps([before, after], ensure_ascii=False, sort_keys=True, default=str))
if before or after
else None
)
session.add(
AuditEvent(
id=uuid7(),
tenant_id=ctx.tenant_id,
actor_kind=actor_kind,
actor_user_id=ctx.user_id if actor_kind == "user" else None,
actor_name=ctx.actor_name or None,
action=action,
object_type=object_type,
object_id=object_id,
project_id=project_id,
before=before,
after=after,
content_digest=digest,
request_id=ctx.request_id or None,
)
)
async def events_for(session: AsyncSession, object_type: str, object_id: UUID) -> list[AuditEvent]:
"""某个对象的修改记录,按时间倒序(FR-M2-08「谁在何时改了什么」)。"""
rows = await session.execute(
select(AuditEvent)
.where(AuditEvent.object_type == object_type, AuditEvent.object_id == object_id)
.order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc())
)
return list(rows.scalars().all())
async def last_before(session: AsyncSession, action: str, object_id: UUID) -> dict[str, Any] | None:
"""某个对象最近一次某种操作之前的状态(例如撤销归档时回到归档前的项目状态)。"""
row = (
await session.execute(
select(AuditEvent.before)
.where(AuditEvent.action == action, AuditEvent.object_id == object_id)
.order_by(AuditEvent.id.desc())
.limit(1)
)
).scalar_one_or_none()
return row if isinstance(row, dict) else None