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:
YANG JIANKUAN
2026-11-03 18:48:10 +08:00
parent 5747a523bb
commit ad7fba6021
106 changed files with 8094 additions and 0 deletions

152
server/tests/conftest.py Normal file
View File

@@ -0,0 +1,152 @@
"""接口集成测试:跑在真实的 PostgreSQL 上(行级安全策略生效),Valkey 用第 1 号库。
需要先起依赖:docker compose -f deploy/compose/compose.yaml up -d postgres valkey gotenberg
每次测试会话重建 bid_doctor_test 库并跑全部迁移。
"""
from __future__ import annotations
import itertools
import os
import tempfile
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
PG = os.environ.get("BD_TEST_PG", "127.0.0.1:54329")
os.environ.update(
{
"BD_ENV": "test",
"BD_DATABASE_URL": f"postgresql+psycopg://bd_app:dev-app@{PG}/bid_doctor_test",
"BD_MIGRATE_DATABASE_URL": f"postgresql+psycopg://bd_owner:dev-owner@{PG}/bid_doctor_test",
"BD_VALKEY_URL": os.environ.get("BD_TEST_VALKEY", "redis://127.0.0.1:63799/1"),
"BD_STORAGE_DIR": tempfile.mkdtemp(prefix="bd-test-objects-"),
"BD_AI_PROVIDER": "fake",
"BD_SMS_PROVIDER": "dev",
}
)
import httpx # noqa: E402
import psycopg # noqa: E402
import pytest # noqa: E402
FIXTURES = Path(__file__).parent / "fixtures"
_phones = itertools.count(13900000001)
def _recreate_database() -> None:
with psycopg.connect(f"postgresql://bd_owner:dev-owner@{PG}/postgres", autocommit=True) as conn:
conn.execute("DROP DATABASE IF EXISTS bid_doctor_test WITH (FORCE)")
conn.execute("CREATE DATABASE bid_doctor_test")
conn.execute("GRANT CONNECT ON DATABASE bid_doctor_test TO bd_app")
@pytest.fixture(scope="session", autouse=True)
def database() -> None:
_recreate_database()
from alembic import command
from alembic.config import Config
command.upgrade(Config(str(Path(__file__).resolve().parents[1] / "alembic.ini")), "head")
@pytest.fixture(scope="session", autouse=True)
async def _valkey() -> AsyncIterator[None]:
from bid_doctor.core.valkey import valkey
await valkey().flushdb()
yield
await valkey().flushdb()
@pytest.fixture(scope="session")
async def app_client() -> AsyncIterator[httpx.AsyncClient]:
from bid_doctor.app import create_app
transport = httpx.ASGITransport(app=create_app())
async with httpx.AsyncClient(transport=transport, base_url="http://test/api/v1") as c:
yield c
class User:
"""一个注册好的企业用户(桌面版 Bearer 令牌)。"""
def __init__(self, client: httpx.AsyncClient, token: str, me: dict[str, Any], phone: str) -> None:
self.client, self.token, self.me, self.phone = client, token, me, phone
self.headers = {"Authorization": f"Bearer {token}"}
async def get(self, path: str, **kw: Any) -> httpx.Response:
return await self.client.get(path, headers=self.headers, **kw)
async def post(self, path: str, **kw: Any) -> httpx.Response:
return await self.client.post(path, headers=self.headers, **kw)
async def put(self, path: str, **kw: Any) -> httpx.Response:
return await self.client.put(path, headers=self.headers, **kw)
@property
def tenant_id(self) -> str:
return str(self.me["tenant"]["id"])
async def register(client: httpx.AsyncClient, name: str = "测试集成公司") -> User:
phone = str(next(_phones))
assert (await client.post("/auth/sms-codes", json={"phone": phone, "purpose": "register"})).status_code == 202
r = await client.post(
"/auth/register",
json={
"phone": phone,
"sms_code": "000000",
"tenant_name": name,
"display_name": "张工",
"terms_version": "v1",
"client_kind": "desktop",
},
)
assert r.status_code == 201, r.text
body = r.json()
return User(client, body["token"], body["me"], phone)
@pytest.fixture
async def user(app_client: httpx.AsyncClient) -> User:
return await register(app_client)
async def upload(u: User, filename: str, data: bytes) -> httpx.Response:
r = await u.post("/uploads", json={"filename": filename, "size_bytes": len(data), "purpose": "project_document"})
assert r.status_code == 201, r.text
up = r.json()
size = up["part_size"]
for n in range(1, up["part_count"] + 1):
assert (
await u.put(f"/uploads/{up['id']}/parts/{n}", content=data[(n - 1) * size : n * size])
).status_code == 204
return await u.post(f"/uploads/{up['id']}/complete")
async def project_with_tender(u: User, fixture: str = "tender-mini.pdf") -> tuple[str, str]:
"""建项目并挂上一份招标文件,返回 (项目编号, 文档编号)。"""
project = (await u.post("/projects", json={})).json()
done = await upload(u, fixture, (FIXTURES / fixture).read_bytes())
assert done.status_code == 200, done.text
r = await u.post(f"/projects/{project['id']}/documents", json={"upload_id": done.json()["id"], "role": "tender"})
assert r.status_code == 201, r.text
return project["id"], r.json()["id"]
async def run_parse(u: User, document_id: str) -> dict[str, Any]:
"""发起解析,并在测试进程里直接跑流水线(不经过 worker)。"""
from uuid import UUID
from bid_doctor.document.pipeline import runner
r = await u.post(f"/documents/{document_id}/parse-jobs", json={"expected_charge": 1})
assert r.status_code == 201, r.text
await runner.run(UUID(u.tenant_id), UUID(r.json()["id"]))
return (await u.get(f"/parse-jobs/{r.json()['id']}")).json()
async def balance(u: User) -> int:
accounts = (await u.get("/quota")).json()["accounts"]
return next(a["balance"] for a in accounts if a["unit"] == "parse")