fix: wire contract provider timeout budgets
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 3m46s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 19m11s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 8m8s

This commit is contained in:
2026-08-11 17:07:35 +08:00
parent ff6ea2877e
commit effbefc83d
25 changed files with 364 additions and 28 deletions
@@ -72,6 +72,7 @@ yuqei-contract-platform/services/contract-api/migrations/versions/20260624_0007_
1. `contract-review-worker` 的关键运行变量已从 compose 硬编码调整为 `.env` 可覆盖:
- `CONTRACT_API_DATABASE_URL`
- `CONTRACT_API_AI_PLATFORM_BASE_URL`
- `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` (must exceed the AI Provider request timeout; staging uses 240 seconds for a 180-second Provider budget)
- `CONTRACT_API_REVIEW_WORKER_NAME`
- `CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS`
- `CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT`
@@ -0,0 +1,76 @@
# AI 合同平台 V2 最终生产就绪审计
日期:2026-08-11
## 结论
当前候选版本中可由代码和自动化完成的功能、回归、安全扫描与共享预发布技术验证已通过,最终差异审查未留下已知代码级 P0/P1。本轮修复了 Contract 调用 AI Platform 时固定使用 SDK 30 秒超时的问题,并在真实动态 Provider 上完成了合同审查闭环;同时关闭了生产 Web API 前缀丢失问题。
正式生产仍为 **No-Go**。未启动 production compose,未修改 DNS,未切换生产流量。阻断原因是具名法务、生产基础设施、双节点现场演练和 support access 外部安全集成尚无真实验收证据;这些证据不得由 AI、mock、模板或共享预发布结果代替。
## 本轮技术闭环
1. Contract API 新增 `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS`,范围为 `1..600` 秒,默认 30 秒;review worker、手工 run-once 和 Legal QA 代理均显式传递到 SDK。
2. 共享预发布 Provider 请求预算为 180 秒,Contract 调用预算为 240 秒,避免同期限竞态。
3. production preflight 强制 Contract timeout 至少比 Provider timeout 多 5 秒,并拒绝缺失、非数字、非有限值、越界和倒置预算。
4. Nginx 为同步 `/contract/api/v1/legal/qa` 设置独立 660 秒上限,覆盖 Contract 允许的 600 秒最大值;其他 Contract 路由仍保持 120 秒。
5. Compose 的 migrate、Contract API 和 review worker 三个服务均映射 timeout;共享预发布部署脚本显式写入 Contract 240 秒和 Provider 180 秒,避免重部署恢复默认 30 秒。
6. 生产 Web 使用 `/contract`、`/ai` 边缘前缀时,Contract 与 AI 请求均保留 base path。统一出口还会规范化 33 个旧 AI 代理路由生成的无前缀 URL;角色判断和 Contract 公共健康检查使用移除边缘前缀后的逻辑 API 路径,避免修复路由时引入权限回归。
7. 身份执行器、support access API 和破坏性身份写接口继续关闭。共享预发布 OpenAPI 中包含 `execute`、`support-access` 或 `support_access` 的路由数为 0。
## 自动化验证
| 范围 | 结果 |
| --- | --- |
| Contract API 全量 | `286 passed, 3 skipped`;跳过项仅为需显式 PostgreSQL URL 的并发专项 |
| AI Platform API、AI worker、Python SDK | `162 passed` |
| Infra production release tools | `62 passed` |
| 本轮 timeout 定向回归 | `28 passed` |
| Contract Web 单元测试 | `17 passed`,其中既有入口/会话/工作空间 12 项,生产 API base path、幂等规范化和逻辑权限路径 5 项 |
| Contract Web typecheck | 通过 |
| Next.js production build | 通过,生成 101 个页面/路由 |
| npm audit | 0 vulnerabilities |
| pip-audit | 0 known vulnerabilities |
| Bandit | 0 findings,0 errors |
| tracked source secret scan | 45 个已复核假阳性,0 个新发现 |
| `git diff --check` | 通过 |
另有 3 个真实 PostgreSQL 并发专项已在本轮之前使用 `192.168.3.90:5432/yuqei_contract` 的随机隔离 schema 完成 `3/3`,并已清理测试 schema。
## 共享预发布证据
- 宿主机:`192.168.3.90`
- AI Provider:`ccswitch / gpt-5.6-sol`
- Provider 调用:enabled
- Provider fallback:false
- Provider timeout:180 秒
- Contract timeout:240 秒;API 与 review worker 容器均实际读取为 `240.0`
- Contract API 镜像:`sha256:5072971b7bdda62036791cc5836faece78b5a8615a98fd98ad5e9b4999e3f51a`
- Contract review worker 镜像:`sha256:35a76a9f6dd5dd44f6545d029cd0e621ea909251c7aabccd6e4cd860a37f11be`
- 真实审查:合同 `contract-096914efc203`、任务 `review-e8100aa210d4`,约 93 秒后状态为 `succeeded`,并持久化 AI audit run id
- post-deploy release gate:`pass`;Contract 与 AI schema-check 均为 `ok`
- 8 个共享预发布服务全部 healthy
- 根盘:92% 使用,约 17 GiB 可用;未自动清理镜像、卷或业务数据
远端变更前备份位于 `/opt/apps/ai-contract-platform-v2-smoke/.runtime/pre-contract-timeout-20260811-1650/`。本地 release gate 证据位于忽略目录 `.runtime/postdeploy-release-gate-timeout-fix-20260811/`,不包含服务密钥。
## 正式生产阻断项
1. 具名法务尚未对 10 张知识卡和 18 条真实 Provider 结果签署精确技术包,因此没有真实 `acceptance_run_id`、receipt v2 或 promotion bundle。
2. 四份公开政策及合同生成、条款推荐黄金资产仍缺具名法务正式版本、生效时间和审批记录。
3. support access 尚未接入真实 IdP/MFA、会话绑定、跨节点撤销、真实资源读取、告警消费和具名隐私/法务批准;当前必须保持 deny-only。
4. 尚无正式 `/etc/yuqei/production.env`、production secrets、不可变镜像清单和非模板 production preflight 证据。
5. 正式 TLS 证书、DNS、异地备份挂载、恢复演练和阿里云前端切流证据不存在。
6. 尚未部署至少两个独立生产节点,无法执行真实 canary、撤销 SLA、kill switch 和完整回滚演练。
7. 生产容量与回滚空间仍需运维确认;共享服务器根盘处于 92% 高水位。
## 固定上线顺序
1. 具名法务签署精确技术包和共享 evidence。
2. 在共享预发布执行 `finalize`,生成并验证真实 `acceptance_run_id`、receipt v2 和 promotion bundle。
3. 建立 production env/secrets、TLS、异地备份和不可变镜像,运行非模板 preflight、备份恢复及容量门禁。
4. 部署至少两个生产节点,不切流。
5. 执行只读 canary、跨节点撤销 SLA、kill switch 和回滚演练。
6. 由法务、安全、隐私和运维具名批准后,另行授权 DNS/流量切换。
上述阻断项全部关闭前,禁止启动 production compose,也禁止把共享预发布重命名或宣称为生产。
@@ -9,6 +9,7 @@
"test:auth-entry-routing": "node --no-warnings --experimental-strip-types --test src/lib/auth-entry-routing.test.mjs",
"test:identity-session": "node --no-warnings --experimental-strip-types --test src/lib/identity-session-mapping.test.mjs",
"test:workspace-access": "node --no-warnings --experimental-strip-types --test src/lib/workspace-access-state.test.mjs",
"test:api-base-url": "node --no-warnings --experimental-strip-types --test src/lib/api-base-url.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -1,4 +1,5 @@
import { getContractWebSession } from "@/lib/contract-session";
import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "@/lib/api-base-url";
type AiPlatformAdminRole = "admin" | "legal";
@@ -8,7 +9,7 @@ const aiPlatformBaseUrl =
"http://localhost:8101";
export function buildAiPlatformApiUrl(pathname: string) {
return new URL(pathname, aiPlatformBaseUrl);
return buildApiUrlWithBasePath(pathname, aiPlatformBaseUrl);
}
export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) {
@@ -17,7 +18,8 @@ export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) {
return Response.json({ message: "请先登录。" }, { status: 401 });
}
const allowedRoles = aiPlatformRolesForPath(targetUrl.pathname);
const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, aiPlatformBaseUrl);
const allowedRoles = aiPlatformRolesForPath(apiPathname);
if (!allowedRoles.includes(session.role.toLowerCase() as AiPlatformAdminRole)) {
return Response.json({ message: "当前账号无权访问该 AI 管理接口。" }, { status: 403 });
}
@@ -31,7 +33,11 @@ export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) {
if (serviceSecret) {
headers.set("X-AI-Platform-Service-Key", serviceSecret);
}
return fetch(targetUrl, { cache: "no-store", ...init, headers });
const normalizedTargetUrl = buildApiUrlWithBasePath(
`${apiPathname}${targetUrl.search}${targetUrl.hash}`,
aiPlatformBaseUrl
);
return fetch(normalizedTargetUrl, { cache: "no-store", ...init, headers });
}
export function aiPlatformRolesForPath(pathname: string): readonly AiPlatformAdminRole[] {
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "./api-base-url.ts";
test("preserves a Contract edge prefix and query parameters", () => {
const url = buildApiUrlWithBasePath(
"/api/v1/legal/qa?trace_id=smoke",
"https://api.example.invalid/contract",
);
assert.equal(url.toString(), "https://api.example.invalid/contract/api/v1/legal/qa?trace_id=smoke");
});
test("preserves an AI edge prefix", () => {
const url = buildApiUrlWithBasePath(
"/api/v1/runtime/config-check",
"https://api.example.invalid/ai/",
);
assert.equal(url.toString(), "https://api.example.invalid/ai/api/v1/runtime/config-check");
});
test("keeps direct service URLs unchanged", () => {
const url = buildApiUrlWithBasePath("/api/v1/health", "http://contract-api:8100");
assert.equal(url.toString(), "http://contract-api:8100/api/v1/health");
});
test("normalization is idempotent for an already-prefixed URL", () => {
const url = buildApiUrlWithBasePath(
"/ai/api/v1/audit/ai-runs?status=succeeded",
"https://api.example.invalid/ai",
);
assert.equal(
url.toString(),
"https://api.example.invalid/ai/api/v1/audit/ai-runs?status=succeeded",
);
});
test("returns the logical API path for authorization checks", () => {
assert.equal(
apiPathnameWithoutBasePath("/ai/api/v1/quality/golden-samples", "https://api.example.invalid/ai"),
"/api/v1/quality/golden-samples",
);
assert.equal(
apiPathnameWithoutBasePath("/contract/api/v1/health", "https://api.example.invalid/contract"),
"/api/v1/health",
);
});
@@ -0,0 +1,23 @@
export function buildApiUrlWithBasePath(pathname: string, baseUrl: string): URL {
const base = new URL(baseUrl);
const requested = new URL(pathname, "http://yuqei-internal.invalid");
const basePath = base.pathname.replace(/\/+$/, "");
const target = new URL(base.origin);
const apiPathname = apiPathnameWithoutBasePath(requested.pathname, baseUrl);
target.pathname = `${basePath}${apiPathname}` || "/";
target.search = requested.search;
target.hash = requested.hash;
return target;
}
export function apiPathnameWithoutBasePath(pathname: string, baseUrl: string): string {
const basePath = new URL(baseUrl).pathname.replace(/\/+$/, "");
if (!basePath || pathname === basePath) {
return pathname === basePath ? "/" : pathname;
}
if (pathname.startsWith(`${basePath}/`)) {
return pathname.slice(basePath.length);
}
return pathname;
}
@@ -1,4 +1,5 @@
import { getContractWebSession } from "@/lib/contract-session";
import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "@/lib/api-base-url";
const contractApiBaseUrl =
process.env.CONTRACT_API_BASE_URL ??
@@ -6,7 +7,7 @@ const contractApiBaseUrl =
"http://localhost:8100";
export function buildContractApiUrl(pathname: string) {
return new URL(pathname, contractApiBaseUrl);
return buildApiUrlWithBasePath(pathname, contractApiBaseUrl);
}
export async function fetchContractApi(targetUrl: URL, init?: RequestInit) {
@@ -26,7 +27,12 @@ export async function fetchContractApi(targetUrl: URL, init?: RequestInit) {
headers.set("X-Contract-Proxy-Key", proxySecret);
}
return fetch(targetUrl, { cache: "no-store", ...init, headers });
const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, contractApiBaseUrl);
const normalizedTargetUrl = buildApiUrlWithBasePath(
`${apiPathname}${targetUrl.search}${targetUrl.hash}`,
contractApiBaseUrl
);
return fetch(normalizedTargetUrl, { cache: "no-store", ...init, headers });
}
export function buildIdentityProxyHeaders(request: Request, initial?: HeadersInit) {
@@ -47,7 +53,8 @@ export function buildIdentityProxyHeaders(request: Request, initial?: HeadersIni
export async function proxyContractApiResponse(targetUrl: URL, init?: RequestInit) {
try {
const session = await getContractWebSession();
if (!session && !isPublicContractApiPath(targetUrl.pathname)) {
const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, contractApiBaseUrl);
if (!session && !isPublicContractApiPath(apiPathname)) {
return Response.json({ message: "请先登录。" }, { status: 401 });
}
const response = await fetchContractApi(targetUrl, init);
@@ -1,4 +1,4 @@
import { fetchContractApi } from "@/lib/contract-api";
import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api";
import { buildAiPlatformApiUrl, fetchAiPlatform } from "@/lib/ai-platform-api";
import { getContractWebSession } from "@/lib/contract-session";
import type { UserRole } from "@/lib/access-control";
@@ -1263,11 +1263,6 @@ export type UnifiedSearchQuery = {
page_size?: number;
};
const contractApiBaseUrl =
process.env.CONTRACT_API_BASE_URL ??
process.env.NEXT_PUBLIC_CONTRACT_API_BASE_URL ??
"http://localhost:8100";
const serverFetchCache = new Map<string, { expiresAt: number; payload: unknown }>();
export async function getContracts(query: ContractQuery = {}): Promise<ContractListItem[]> {
@@ -2026,7 +2021,7 @@ async function fetchAiJson<T>(
function resolveUrl(apiPathname: string, proxyPathname: string) {
if (typeof window === "undefined") {
return new URL(apiPathname, contractApiBaseUrl).toString();
return buildContractApiUrl(apiPathname).toString();
}
return proxyPathname;
}
@@ -6,6 +6,7 @@ CONTRACT_API_SEED_DEMO_DATA=true
CONTRACT_API_FILE_STORAGE_DIR=.runtime/contract-files
CONTRACT_API_REDIS_URL=redis://localhost:6379/0
CONTRACT_API_AI_PLATFORM_BASE_URL=http://localhost:8101
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=30
CONTRACT_API_UVICORN_WORKERS=1
CONTRACT_API_READ_CACHE_TTL_SECONDS=2
CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1
@@ -65,6 +65,7 @@ CONTRACT_API_DATABASE_AUTO_CREATE=false
CONTRACT_API_SEED_DEMO_DATA=false
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60
CONTRACT_API_UVICORN_WORKERS=1
CONTRACT_API_READ_CACHE_TTL_SECONDS=2
CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1
@@ -76,6 +77,8 @@ CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true
CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=LOAD_A_STABLE_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS
```
`CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` covers the complete Contract-to-AI request and must exceed `AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS` by at least five seconds. For example, use `240` when staging a Provider timeout of `180` seconds.
The disabled mode keeps login and administrator-managed one-time invitation links available while public company registration, password reset, and email verification remain off. When enterprise email becomes available, switch to `smtp`, set manual invitations to `false`, and configure the SMTP variables from `.env.example`. The production web proxy must overwrite public `X-Real-IP` / `X-Forwarded-For` headers before Contract Web forwards the validated client IP to Contract API for rate limiting.
After migrations, initialize the first production administrator from a protected password file. The command is idempotent and will not replace an existing credential unless `--force-rotate` is supplied:
@@ -302,8 +302,11 @@ def register_contract_core_routes(
)
def proxy_legal_qa(request: LegalQaRequest) -> LegalQaResponse:
authorization.require("start_review")
client_options = {"service_key": settings.ai_platform_service_key} if settings.ai_platform_service_key else {}
with AiPlatformClient(settings.ai_platform_base_url, **client_options) as client:
with AiPlatformClient(
settings.ai_platform_base_url,
timeout=settings.ai_platform_timeout_seconds,
service_key=settings.ai_platform_service_key or None,
) as client:
return client.run_legal_qa(
request,
context=RequestContext(tenant_id=get_request_tenant_id() or "default", user_id="contract-api"),
@@ -218,6 +218,7 @@ def register_review_routes(
repository=repository,
ai_platform_base_url=settings.ai_platform_base_url,
ai_platform_service_key=settings.ai_platform_service_key,
ai_platform_timeout_seconds=settings.ai_platform_timeout_seconds,
options=ReviewWorkerOptions(
worker_name=payload.worker_name or settings.review_worker_name,
limit=payload.limit,
@@ -22,6 +22,7 @@ class ContractApiSettings(BaseSettings):
redis_url: str = "redis://localhost:6379/0"
ai_platform_base_url: str = "http://localhost:8101"
ai_platform_service_key: str = ""
ai_platform_timeout_seconds: float = Field(default=30.0, ge=1.0, le=600.0)
web_proxy_shared_secret: str = ""
require_web_proxy_auth: bool = False
review_worker_name: str = "contract-review-worker-1"
@@ -163,7 +164,11 @@ def _check_ai_platform_config(settings: ContractApiSettings) -> RuntimeConfigChe
name="ai_platform_base_url",
status="ok",
message="AI platform base URL is configured.",
details={"scheme": parsed.scheme, "host": parsed.netloc},
details={
"scheme": parsed.scheme,
"host": parsed.netloc,
"timeout_seconds": settings.ai_platform_timeout_seconds,
},
)
@@ -27,6 +27,7 @@ def run_review_worker_once(
repository: ContractRepository,
ai_platform_base_url: str,
ai_platform_service_key: str | None = None,
ai_platform_timeout_seconds: float = 30.0,
options: ReviewWorkerOptions,
) -> ReviewWorkerRun:
max_tasks = max(options.limit, 1)
@@ -46,6 +47,7 @@ def run_review_worker_once(
task=task,
ai_platform_base_url=ai_platform_base_url,
ai_platform_service_key=ai_platform_service_key,
ai_platform_timeout_seconds=ai_platform_timeout_seconds,
options=options,
)
if result.status == "succeeded":
@@ -67,6 +69,7 @@ def execute_review_task(
task: ReviewTask,
ai_platform_base_url: str,
ai_platform_service_key: str | None = None,
ai_platform_timeout_seconds: float = 30.0,
options: ReviewWorkerOptions,
) -> ReviewTask:
contract = repository.get_contract(task.contract_id)
@@ -88,8 +91,11 @@ def execute_review_task(
return failed or task
try:
client_options = {"service_key": ai_platform_service_key} if ai_platform_service_key else {}
with AiPlatformClient(ai_platform_base_url, **client_options) as client:
with AiPlatformClient(
ai_platform_base_url,
timeout=ai_platform_timeout_seconds,
service_key=ai_platform_service_key or None,
) as client:
review = client.run_contract_review(
ContractReviewRequest(
contract_title=contract.title,
@@ -220,6 +226,7 @@ def main() -> None:
repository=repository,
ai_platform_base_url=settings.ai_platform_base_url,
ai_platform_service_key=settings.ai_platform_service_key,
ai_platform_timeout_seconds=settings.ai_platform_timeout_seconds,
options=ReviewWorkerOptions(
worker_name=settings.review_worker_name,
limit=settings.review_worker_batch_limit,
@@ -235,6 +242,7 @@ def run_once(settings: ContractApiSettings | None = None, *, limit: int | None =
repository=repository,
ai_platform_base_url=resolved_settings.ai_platform_base_url,
ai_platform_service_key=resolved_settings.ai_platform_service_key,
ai_platform_timeout_seconds=resolved_settings.ai_platform_timeout_seconds,
options=ReviewWorkerOptions(
worker_name=resolved_settings.review_worker_name,
limit=limit or resolved_settings.review_worker_batch_limit,
@@ -2275,8 +2275,16 @@ def test_postgresql_unified_search_keeps_full_text_as_bounded_fallback(monkeypat
def test_review_worker_calls_ai_platform_and_persists_result(tmp_path: Path, monkeypatch) -> None:
class FakeAiPlatformClient:
def __init__(self, base_url: str) -> None:
def __init__(
self,
base_url: str,
*,
timeout: float,
service_key: str | None = None,
) -> None:
self.base_url = base_url
assert timeout == 180.0
assert service_key is None
def __enter__(self):
return self
@@ -2317,7 +2325,8 @@ def test_review_worker_calls_ai_platform_and_persists_result(tmp_path: Path, mon
)
monkeypatch.setattr(contract_review_worker, "AiPlatformClient", FakeAiPlatformClient)
app = create_app(make_settings(tmp_path))
settings = make_settings(tmp_path).model_copy(update={"ai_platform_timeout_seconds": 180.0})
app = create_app(settings)
client = TestClient(app)
create_response = client.post(
@@ -2387,7 +2396,12 @@ def test_review_worker_route_forwards_ai_service_key(tmp_path: Path, monkeypatch
}
monkeypatch.setattr("yuqei_contract_api.api.review.run_review_worker_once", fake_run_review_worker_once)
settings = make_settings(tmp_path).model_copy(update={"ai_platform_service_key": "test-service-key"})
settings = make_settings(tmp_path).model_copy(
update={
"ai_platform_service_key": "test-service-key",
"ai_platform_timeout_seconds": 180.0,
}
)
app = create_app(settings)
client = TestClient(app)
@@ -2395,6 +2409,7 @@ def test_review_worker_route_forwards_ai_service_key(tmp_path: Path, monkeypatch
assert response.status_code == 200
assert captured["ai_platform_service_key"] == "test-service-key"
assert captured["ai_platform_timeout_seconds"] == 180.0
def test_review_clause_repair_can_append_recommended_clause_to_contract_body(tmp_path: Path) -> None:
@@ -3454,7 +3469,7 @@ def test_legal_change_impact_scan_uses_published_clause_snapshot(tmp_path: Path)
def test_review_worker_uses_uploaded_source_file_text_and_persists_ai_sources(tmp_path: Path, monkeypatch) -> None:
class FakeAiPlatformClient:
def __init__(self, base_url: str) -> None:
def __init__(self, base_url: str, **_kwargs) -> None:
self.base_url = base_url
def __enter__(self):
@@ -3532,7 +3547,7 @@ def test_review_worker_does_not_fallback_to_contract_body_when_source_file_is_un
calls: list[str] = []
class FakeAiPlatformClient:
def __init__(self, base_url: str) -> None:
def __init__(self, base_url: str, **_kwargs) -> None:
self.base_url = base_url
def __enter__(self):
@@ -3584,7 +3599,7 @@ def test_review_worker_does_not_fallback_to_contract_body_when_source_file_is_un
def test_failed_review_task_can_be_requeued(tmp_path: Path, monkeypatch) -> None:
class FailingAiPlatformClient:
def __init__(self, base_url: str) -> None:
def __init__(self, base_url: str, **_kwargs) -> None:
self.base_url = base_url
def __enter__(self):
@@ -1,4 +1,6 @@
from fastapi.testclient import TestClient
import pytest
from pydantic import ValidationError
from sqlalchemy import text
from yuqei_contract_api.config import ContractApiSettings
@@ -90,6 +92,12 @@ def test_runtime_config_check_flags_production_localhost_ai_platform() -> None:
)
@pytest.mark.parametrize("timeout", [0, 0.5, 600.1, 601])
def test_ai_platform_timeout_must_stay_within_supported_range(timeout: float) -> None:
with pytest.raises(ValidationError):
ContractApiSettings(ai_platform_timeout_seconds=timeout)
def test_runtime_config_check_flags_empty_review_worker_name() -> None:
app = create_app(
ContractApiSettings(
@@ -17,6 +17,9 @@ def make_test_settings() -> ContractApiSettings:
def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None:
from yuqei_sdk import ai_platform
captured_options: dict[str, object] = {}
fake_service_key = "legal-qa-service-key" # pragma: allowlist secret
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/legal/qa"
assert request.headers["x-tenant-id"] == "tenant-qa"
@@ -45,6 +48,7 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None:
original_init = ai_platform.AiPlatformClient.__init__
def init_with_mock_transport(self, base_url: str, **kwargs) -> None:
captured_options.update(kwargs)
original_init(
self,
base_url,
@@ -54,7 +58,13 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None:
monkeypatch.setattr(ai_platform.AiPlatformClient, "__init__", init_with_mock_transport)
app = create_app(make_test_settings())
settings = make_test_settings().model_copy(
update={
"ai_platform_service_key": fake_service_key,
"ai_platform_timeout_seconds": 45.0,
}
)
app = create_app(settings)
client = TestClient(app)
response = client.post(
"/api/v1/legal/qa",
@@ -65,3 +75,5 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None:
assert response.status_code == 200
payload = response.json()
assert payload["citations"][0]["reference"] == "\u7b2c\u4e94\u767e\u96f6\u4e5d\u6761"
assert captured_options["timeout"] == 45.0
assert captured_options["service_key"] == fake_service_key
+3
View File
@@ -251,6 +251,7 @@ CONTRACT_API_DATABASE_URL=postgresql+psycopg://.../yuqei_contract
CONTRACT_API_DATABASE_AUTO_CREATE=false
CONTRACT_API_SEED_DEMO_DATA=false
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60
CONTRACT_API_UVICORN_WORKERS=1
CONTRACT_API_READ_CACHE_TTL_SECONDS=2
CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1
@@ -264,6 +265,8 @@ YUQEI_WORKER_DATABASE_URL=postgresql+psycopg://.../yuqei_ai
YUQEI_WORKER_DATABASE_AUTO_CREATE=false
```
Keep `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` at least five seconds above `AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS`; production preflight rejects missing, non-finite, out-of-range, or inverted timeout budgets.
Start from `yuqei-infra\compose\.env.production.example` and replace every `CHANGE_ME` value before deployment.
Run migrations explicitly before replacing application containers.
+1
View File
@@ -27,6 +27,7 @@ CONTRACT_API_SEED_DEMO_DATA=true
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
CONTRACT_API_REDIS_URL=redis://redis:6379/0
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=30
CONTRACT_API_WEB_PROXY_SHARED_SECRET=local-only-proxy-secret-change-me-32
CONTRACT_API_REQUIRE_WEB_PROXY_AUTH=false
CONTRACT_API_UVICORN_WORKERS=1
@@ -67,6 +67,7 @@ CONTRACT_API_SEED_DEMO_DATA=false
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
CONTRACT_API_REDIS_URL=redis://redis:6379/0
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60
CONTRACT_API_WEB_PROXY_SHARED_SECRET=CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET
CONTRACT_API_REQUIRE_WEB_PROXY_AUTH=true
CONTRACT_API_UVICORN_WORKERS=1
+9
View File
@@ -86,6 +86,7 @@ services:
CONTRACT_API_FILE_STORAGE_DIR: ${CONTRACT_API_FILE_STORAGE_DIR:-/data/contract-files}
CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0}
CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101}
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30}
CONTRACT_API_DB_POOL_SIZE: ${CONTRACT_API_DB_POOL_SIZE:-10}
CONTRACT_API_DB_MAX_OVERFLOW: ${CONTRACT_API_DB_MAX_OVERFLOW:-20}
CONTRACT_API_DB_POOL_TIMEOUT_SECONDS: ${CONTRACT_API_DB_POOL_TIMEOUT_SECONDS:-30}
@@ -113,6 +114,7 @@ services:
CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0}
CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101}
CONTRACT_API_AI_PLATFORM_SERVICE_KEY: ${AI_PLATFORM_SERVICE_SHARED_SECRET:-local-only-ai-service-secret-change-me-32}
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30}
CONTRACT_API_WEB_PROXY_SHARED_SECRET: ${CONTRACT_API_WEB_PROXY_SHARED_SECRET:-local-only-proxy-secret-change-me-32}
CONTRACT_API_REQUIRE_WEB_PROXY_AUTH: ${CONTRACT_API_REQUIRE_WEB_PROXY_AUTH:-false}
CONTRACT_API_UVICORN_WORKERS: ${CONTRACT_API_UVICORN_WORKERS:-1}
@@ -121,6 +123,12 @@ services:
CONTRACT_API_DB_POOL_TIMEOUT_SECONDS: ${CONTRACT_API_DB_POOL_TIMEOUT_SECONDS:-30}
CONTRACT_API_DB_POOL_RECYCLE_SECONDS: ${CONTRACT_API_DB_POOL_RECYCLE_SECONDS:-1800}
CONTRACT_API_READ_CACHE_TTL_SECONDS: ${CONTRACT_API_READ_CACHE_TTL_SECONDS:-2}
CONTRACT_API_ANTI_ABUSE_MODE: ${CONTRACT_API_ANTI_ABUSE_MODE:-disabled}
CONTRACT_API_ANTI_ABUSE_HASH_SECRET: ${CONTRACT_API_ANTI_ABUSE_HASH_SECRET:-}
CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT:-1000}
CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT:-1000}
CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT:-50}
CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT:-524288000}
CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE: ${CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE:-development}
CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED: ${CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED:-true}
CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED: ${CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED:-false}
@@ -184,6 +192,7 @@ services:
CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0}
CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101}
CONTRACT_API_AI_PLATFORM_SERVICE_KEY: ${AI_PLATFORM_SERVICE_SHARED_SECRET:-local-only-ai-service-secret-change-me-32}
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30}
CONTRACT_API_REVIEW_WORKER_NAME: ${CONTRACT_API_REVIEW_WORKER_NAME:-contract-review-worker-1}
CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS: ${CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS:-5}
CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT: ${CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT:-5}
@@ -6,6 +6,7 @@ from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import re
import stat
@@ -78,7 +79,9 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
"CONTRACT_POSTGRES_DB",
"AI_PLATFORM_POSTGRES_DB",
"CONTRACT_API_DATABASE_URL",
"CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS",
"AI_PLATFORM_DATABASE_URL",
"AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS",
"YUQEI_WORKER_DATABASE_URL",
"MINIO_ROOT_USER",
"MINIO_ROOT_PASSWORD",
@@ -116,6 +119,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
]
checks.append(_result("production_modes", not invalid_exact, "Production modes are strict." if not invalid_exact else f"Invalid production values: {', '.join(invalid_exact)}."))
checks.append(_validate_identity_email_delivery(values))
checks.append(_validate_ai_timeout_budget(values))
placeholders = [
key
@@ -421,6 +425,27 @@ def _parse_int(value: str) -> int | None:
return None
def _validate_ai_timeout_budget(values: dict[str, str]) -> Check:
contract_timeout = _parse_float(values.get("CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS", ""))
provider_timeout = _parse_float(values.get("AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS", ""))
valid = (
contract_timeout is not None
and provider_timeout is not None
and math.isfinite(contract_timeout)
and math.isfinite(provider_timeout)
and 1 <= contract_timeout <= 600
and provider_timeout > 0
and contract_timeout >= provider_timeout + 5
)
return _result(
"ai_timeout_budget",
valid,
"Contract-to-AI timeout leaves at least five seconds beyond the Provider request budget."
if valid
else "Timeouts must be finite and positive, Contract must be within 1..600 seconds, and Contract must exceed Provider by at least five seconds.",
)
def _parse_float(value: str) -> float | None:
try:
return float(value)
@@ -44,6 +44,20 @@ server {
return 200 '{"status":"ok","service":"yuqei-v2-edge"}';
}
location = /contract/api/v1/legal/qa {
limit_req zone=yuqei_api_per_ip burst=20 nodelay;
proxy_pass http://contract_api_upstream/api/v1/legal/qa;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 660s;
proxy_send_timeout 660s;
}
location /contract/ {
limit_req zone=yuqei_api_per_ip burst=40 nodelay;
proxy_pass http://contract_api_upstream/;
@@ -357,6 +357,8 @@ CONTRACT_API_SEED_DEMO_DATA=true
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
CONTRACT_API_REDIS_URL=redis://redis:6379/0
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=240
AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS=180
AI_PLATFORM_SERVICE_SHARED_SECRET=$aiPlatformServiceSecret
AI_PLATFORM_REQUIRE_SERVICE_AUTH=true
CONTRACT_API_WEB_PROXY_SHARED_SECRET=$webProxySharedSecret
@@ -466,15 +468,15 @@ docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $
set -euo pipefail
cd $(ConvertTo-ShellSingleQuote $RemoteProjectPath)
docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T postgres psql -U yuqei -d yuqei_contract -v ON_ERROR_STOP=1 -c "UPDATE review_tasks SET status = 'failed', status_label = 'smoke cleanup', error_message = 'Skipped before deterministic smoke verification.', completed_at = now(), updated_at = now() WHERE status IN ('queued', 'running');" < /dev/null
CREATE_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -d '{"title":"Smoke Review Contract","counterparty":"Smoke Counterparty","source_text":"Smoke contract body. The parties agree on payment, delivery, acceptance, confidentiality, breach liability, dispute resolution, and evidence archive obligations.","owner":"smoke"}')
CREATE_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin' -d '{"title":"Smoke Review Contract","counterparty":"Smoke Counterparty","source_text":"Smoke contract body. The parties agree on payment, delivery, acceptance, confidentiality, breach liability, dispute resolution, and evidence archive obligations.","owner":"smoke"}')
SMOKE_CONTRACT_ID=`$(printf '%s' "`$CREATE_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; print(json.load(sys.stdin)["id"])')
TASK_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -d '{"actor_name":"smoke","comment":"deterministic review worker smoke"}')
TASK_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin' -d '{"actor_name":"smoke","comment":"deterministic review worker smoke"}')
SMOKE_TASK_ID=`$(printf '%s' "`$TASK_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; print(json.load(sys.stdin)["id"])')
SMOKE_OUTPUT=`$(docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-review-worker python -m yuqei_contract_api.review_worker --run-once --limit 1 < /dev/null)
echo "`$SMOKE_OUTPUT"
printf '%s' "`$SMOKE_OUTPUT" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; payload = json.load(sys.stdin); raise SystemExit(1 if payload.get("failed", 0) > 0 else 0)'
for i in `$(seq 1 30); do
TASKS_RESPONSE=`$(curl -fsS http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review-tasks -H 'X-Contract-Proxy-Key: $webProxySharedSecret')
TASKS_RESPONSE=`$(curl -fsS http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review-tasks -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin')
TASK_STATUS=`$(printf '%s' "`$TASKS_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T -e SMOKE_TASK_ID="`$SMOKE_TASK_ID" contract-api python -c 'import json, os, sys; task_id = os.environ["SMOKE_TASK_ID"]; rows = json.load(sys.stdin); print(next((item.get("status", "missing") for item in rows if item.get("id") == task_id), "missing"))')
echo "smoke_review_task_status=`$TASK_STATUS"
if [ "`$TASK_STATUS" = "succeeded" ]; then
@@ -92,6 +92,23 @@ def test_production_preflight_accepts_strict_configuration() -> None:
assert [check.key for check in checks if check.status == "fail"] == []
@pytest.mark.parametrize(
("contract_timeout", "provider_timeout"),
[("30", "30"), ("0", "30"), ("601", "30"), ("NaN", "30"), ("60", "inf")],
)
def test_production_preflight_rejects_invalid_ai_timeout_budgets(
contract_timeout: str,
provider_timeout: str,
) -> None:
values = production_values()
values["CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS"] = contract_timeout
values["AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS"] = provider_timeout
checks = {check.key: check for check in preflight.validate_environment(values)}
assert checks["ai_timeout_budget"].status == "fail"
def test_production_preflight_rejects_promotion_image_digest_drift() -> None:
values = production_values()
values["AI_PLATFORM_RELEASE_IMAGE_DIGEST"] = f"sha256:{'f' * 64}"
@@ -197,6 +214,55 @@ def test_production_compose_mounts_only_promotion_verification_material_read_onl
assert "STAGING_DATABASE_URL" not in override
def test_shared_compose_wires_contract_anti_abuse_controls() -> None:
compose = (REPO_ROOT / "yuqei-infra/compose/docker-compose.yml").read_text(
encoding="utf-8"
)
for variable in (
"CONTRACT_API_ANTI_ABUSE_MODE",
"CONTRACT_API_ANTI_ABUSE_HASH_SECRET",
"CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT",
"CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT",
"CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT",
"CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT",
):
assert f"{variable}: ${{{variable}:-" in compose
def test_shared_compose_and_smoke_deploy_wire_contract_ai_timeout() -> None:
compose = (REPO_ROOT / "yuqei-infra/compose/docker-compose.yml").read_text(
encoding="utf-8"
)
deploy_script = (
REPO_ROOT / "yuqei-infra/scripts/deploy-v2-smoke-docker.ps1"
).read_text(encoding="utf-8")
assert compose.count("CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${") == 3
assert "CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=240" in deploy_script
assert "AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS=180" in deploy_script
def test_legal_qa_edge_timeout_covers_contract_client_maximum() -> None:
nginx = (
REPO_ROOT / "yuqei-infra/nginx/templates/default.conf.template"
).read_text(encoding="utf-8")
location = nginx.split("location = /contract/api/v1/legal/qa", 1)[1].split("}", 1)[0]
assert "proxy_read_timeout 660s;" in location
assert "proxy_send_timeout 660s;" in location
assert "proxy_pass http://contract_api_upstream/api/v1/legal/qa;" in location
def test_smoke_review_requests_include_server_authorized_identity_context() -> None:
deploy_script = (
REPO_ROOT / "yuqei-infra/scripts/deploy-v2-smoke-docker.ps1"
).read_text(encoding="utf-8")
assert deploy_script.count("-H 'X-Tenant-Id: default'") >= 3
assert deploy_script.count("-H 'X-User-Id: user-current-admin'") >= 3
def test_disabled_identity_email_does_not_require_smtp_secret_file() -> None:
values = production_values()
values.pop("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST", None)