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>
This commit is contained in:
190
server/src/bid_doctor/document/api.py
Normal file
190
server/src/bid_doctor/document/api.py
Normal file
@@ -0,0 +1,190 @@
|
||||
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))
|
||||
Reference in New Issue
Block a user