Files
bid-doctor/server/src/bid_doctor/document/api.py
YANG JIANKUAN ad7fba6021 feat(server): 工程骨架后端——上传 → 解析 → 清单最小链路
- core:配置(prod 拒绝默认密钥 / dev 短信 / 假模型)、租户事务 + 行级安全策略、
  problem+json 错误码、信封加密与签名下载、对象存储、事务内入队、Valkey stream 事件流、权限点
- identity:手机号 HMAC 存储、注册、登录(挤下线需确认,API3)、服务端会话
- billing:额度流水只追加,预扣 / 结算 / 退回,一笔预扣只有一个结局
- document:分片上传、按文件头识别格式、Gotenberg 转换、文字层与稳定句子编号
- checklist:标注规则识别、模型抽取、三道校验、标注类条款规则兜底、重跑对齐
- ai:提示词注册表(extract.substantive v1 来自 S4 v2)、假模型、OpenAI 兼容供应商、ai_call 流水
- 迁移:data-model 基线、套餐、Procrastinate、会话状态函数
- 测试 30 个:租户隔离、额度、失败退回、对象加密、契约一致性
- Compose(含 migrate)、镜像、CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:48:10 +08:00

191 lines
7.2 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.

from __future__ import annotations
import json
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Body, Query, Request
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy import select
from bid_doctor.core import keys
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import open_object, sign_download, verify_download
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.storage import storage
from bid_doctor.document import service, uploads
from bid_doctor.document.models import DocumentPage, SourceDocument
from bid_doctor.identity.deps import Ctx
router = APIRouter(tags=["uploads", "documents"])
class UploadIn(BaseModel):
filename: str = Field(max_length=255)
size_bytes: int = Field(ge=1)
sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
purpose: Literal["project_document", "qualification_file"]
class DocumentIn(BaseModel):
upload_id: UUID
role: Literal["tender", "addendum", "clarification", "bid_volume"]
volume_name: str | None = Field(default=None, max_length=40)
class ParseIn(BaseModel):
expected_charge: int | None = None
@router.post("/uploads", status_code=201)
async def create_upload(body: UploadIn, ctx: Ctx) -> dict[str, Any]:
return await uploads.create(ctx, body.filename, body.size_bytes, body.purpose, body.sha256)
@router.get("/uploads/{upload_id}")
async def get_upload(upload_id: UUID, ctx: Ctx) -> dict[str, Any]:
return await uploads.describe(ctx, upload_id)
@router.put("/uploads/{upload_id}/parts/{part_no}", status_code=204)
async def put_part(upload_id: UUID, part_no: int, request: Request, ctx: Ctx) -> None:
await uploads.put_part(ctx, upload_id, part_no, await request.body())
@router.post("/uploads/{upload_id}/complete")
async def complete_upload(upload_id: UUID, ctx: Ctx) -> dict[str, Any]:
return await uploads.complete(ctx, upload_id)
@router.delete("/uploads/{upload_id}", status_code=204)
async def abort_upload(upload_id: UUID, ctx: Ctx) -> None:
await uploads.discard(ctx, upload_id)
@router.get("/projects/{project_id}/documents")
async def list_documents(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
docs = (
(
await s.execute(
select(SourceDocument)
.where(SourceDocument.project_id == project_id)
.order_by(SourceDocument.created_at)
)
)
.scalars()
.all()
)
return {"items": [await service.doc_to_api(s, d) for d in docs]}
@router.post("/projects/{project_id}/documents", status_code=201)
async def create_document(project_id: UUID, body: DocumentIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
doc = await service.create(s, ctx, project_id, body.upload_id, body.role, body.volume_name)
return await service.doc_to_api(s, doc)
@router.get("/documents/{document_id}")
async def get_document(document_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.doc_to_api(s, await service.get(s, document_id))
@router.get("/documents/{document_id}/pages")
async def list_pages(document_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
doc = await service.get(s, document_id)
pages = (
(
await s.execute(
select(DocumentPage).where(DocumentPage.document_id == doc.id).order_by(DocumentPage.page_no)
)
)
.scalars()
.all()
)
return {
"normalized_sha256": doc.normalized_sha256.hex() if doc.normalized_sha256 else None,
"items": [
{
"page_no": p.page_no,
"width_pt": p.width_pt,
"height_pt": p.height_pt,
"rotation": p.rotation,
"text_source": p.text_source,
}
for p in pages
],
}
@router.get("/documents/{document_id}/pages/{page_no}/sentences")
async def list_sentences(document_id: UUID, page_no: int, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
doc = await service.get(s, document_id)
dk = await keys.data_key(s, ctx, doc.project_id)
if not doc.text_layer_key:
raise AppError("document_not_ready")
pages = json.loads(open_object(dk, storage().get(doc.text_layer_key), doc.text_layer_key))
page = next((p for p in pages if p["page_no"] == page_no), None)
if page is None:
raise AppError("not_found")
return {"items": page["sentences"]}
@router.get("/documents/{document_id}/file")
async def get_file_url(
document_id: UUID, ctx: Ctx, variant: Literal["normalized", "original"] = Query()
) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
doc = await service.get(s, document_id)
if variant == "normalized" and not doc.normalized_key:
raise AppError("document_not_ready")
payload = f"{ctx.tenant_id}:{doc.id}:{variant}"
sig, exp = sign_download(payload, get_settings().download_url_ttl_s)
from datetime import UTC, datetime
url = f"{get_settings().public_base_url}/api/v1/files/{ctx.tenant_id}/{doc.id}/{variant}?exp={exp}&sig={sig}"
return {"url": url, "expires_at": datetime.fromtimestamp(exp, UTC)}
@router.get("/files/{tenant_id}/{document_id}/{variant}", include_in_schema=False)
async def download(
tenant_id: UUID, document_id: UUID, variant: Literal["normalized", "original"], exp: int, sig: str
) -> Response:
"""签名下载(本地磁盘驱动用)。不带鉴权头,靠短时签名;S3 驱动时直接返回对象存储的预签名地址。"""
if not verify_download(f"{tenant_id}:{document_id}:{variant}", exp, sig):
raise AppError("not_found")
ctx = TenantContext(tenant_id=tenant_id, user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
doc = await service.get(s, document_id)
dk = await keys.data_key(s, ctx, doc.project_id)
key = doc.normalized_key if variant == "normalized" else doc.original_key
assert key is not None
media = "application/pdf" if variant == "normalized" else "application/octet-stream"
return Response(
open_object(dk, storage().get(key), key), media_type=media, headers={"Cache-Control": "private, max-age=300"}
)
@router.post("/documents/{document_id}/parse-jobs", status_code=201)
async def start_parse(document_id: UUID, ctx: Ctx, body: Annotated[ParseIn | None, Body()] = None) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
job = await service.start_parse(s, ctx, document_id, body.expected_charge if body else None)
return service.job_to_api(job)
@router.get("/parse-jobs/{job_id}")
async def get_parse_job(job_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return service.job_to_api(await service.get_job(s, job_id))
@router.post("/parse-jobs/{job_id}/cancel", status_code=202)
async def cancel_parse_job(job_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return service.job_to_api(await service.request_cancel(s, job_id))