From 6abca4eb75cc126dcbeab659c954331cccd376ee Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 13 Jul 2026 13:47:49 +0800 Subject: [PATCH] fix: restore authenticated server-rendered data --- ...-operations-launch-readiness-2026-07-13.md | 33 +++++++---- yuqei-ai-sdk-python/tests/test_ai_platform.py | 2 +- .../src/app/admin/identity/page.tsx | 10 ++-- .../src/app/legal-qa/legal-qa-client.tsx | 3 +- .../src/services/contracts-client.ts | 58 +++++++++++++++++++ .../contract-web/src/services/contracts.ts | 15 ++++- yuqei-infra/scripts/compare-v1-v2-assets.py | 58 +++++++++++++++---- 7 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts diff --git a/project-docs/V2-operations-launch-readiness-2026-07-13.md b/project-docs/V2-operations-launch-readiness-2026-07-13.md index a2568003..b405b2cd 100644 --- a/project-docs/V2-operations-launch-readiness-2026-07-13.md +++ b/project-docs/V2-operations-launch-readiness-2026-07-13.md @@ -4,8 +4,8 @@ ## 结论 -- **内部 UAT / 受控单租户试点:有条件可进入。** 当前版本需先部署到 `192.168.3.90` smoke,并通过本文列出的安全、身份和真实 AI 抽样门槛。 -- **公开多企业 SaaS 生产:当前 No-Go。** 阻断项不是模板或 Prompt 数量,而是真实邮件交付、真实 AI Provider 评估、内容法务签批和本轮身份安全改动尚未完成远端验证。 +- **内部 UAT / 受控单租户试点:可以进入。** 当前候选版本已部署到 `192.168.3.90` smoke,并通过远端身份边界、核心页面、资产计数、schema-check 和 Docker smoke 验证。 +- **公开多企业 SaaS 生产:当前 No-Go。** 阻断项不是模板或 Prompt 数量,而是真实邮件交付、真实 AI Provider 评估、内容法务签批和生产运行保障。 - 不建议继续横向增加模板数量。下一阶段应优先完成内容治理、真实模型质量评估和生产运行保障。 ## 当前能力 @@ -22,7 +22,7 @@ 资产精确核对证据: -- `project-docs/qa-artifacts/v2-asset-count/v2-asset-count-2026-07-13T02-53-18.128434+00-00.json` +- `project-docs/qa-artifacts/v2-asset-count/v2-asset-count-2026-07-13T05-33-30.778054+00-00.json` - 模板:`30 / 30 / 30` - 标准条款:`8 / 8 / 8` - 审查规则:`8 / 8 / 8` @@ -30,7 +30,7 @@ ## 本轮安全硬化 -本轮本地代码已完成: +本轮代码和远端 smoke 已完成: 1. Web 会话从可篡改的 Base64 JSON 改为带有效期的 HMAC 签名 cookie,并启用 `HttpOnly`、`SameSite=Lax` 和生产 `Secure`。 2. Contract API 不再在生产环境无条件信任 `X-Tenant-Id` / `X-User-Id`,由受控 Web 代理密钥建立信任边界。 @@ -39,16 +39,29 @@ 5. Contract API、审查 worker 和 Python SDK 支持向 AI Platform 传递服务密钥。 6. 生产响应不再返回邀请原始 token 和邮箱开发验证码。 -这些变更在本地测试和生产构建通过,但在部署到 smoke 前仍属于“本地已修、远端待验”。 +这些变更已通过本地测试、生产构建和远端 smoke 验证。 + +## 远端验证证据 + +- Docker smoke:`project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-13T13-18-14/v2-smoke-docker-deploy-summary.md`,8 个长期服务健康,迁移、Prompt 导入、黄金样本和审查 worker smoke 通过。 +- 鉴权边界:Contract API 与 AI Platform 数据接口缺失或使用错误服务密钥时返回 `401`,使用当前 smoke 服务密钥时返回 `200`;健康端点保持可用。 +- Schema:Contract API `status=ok`,Alembic `20260710_0030`;AI Platform Alembic `20260703_0021`,仅 `provider_runtime=warning`,原因是 smoke 明确关闭真实 Provider 并启用 fallback。 +- Web 登录:`admin@example.com` 可进入默认租户;合同台账显示 `12,129` 条,模板库和模板起草入口均显示 30 个模板,文件仓库、AI Trace、用户租户页均正常加载。 +- SSR 认证回归:修复了认证硬化后 Server Component 裸请求 Contract API 导致合同和模板页面错误显示为 0 的问题;服务端缓存键已纳入租户、用户和角色,浏览器端继续只访问同源代理。 +- Word 交付:抽样合同下载返回 `HT-20260713-70C052.docx`,MIME 为 Office Open XML,解码后 37,502 字节且 DOCX 文件头有效。 +- 资产精确核对:模板 `30 / 30 / 30`、条款 `8 / 8 / 8`、规则 `8 / 8 / 8`、Prompt `46 / 46 / 46`,法律条文运行时 `789,867` 条。 +- 法律问答工程链路可用,但 smoke fallback 抽样的引用相关性不足,不能作为生产 AI 质量证据;必须在真实 Provider、fallback 关闭后重新验收。 +- PR 旧提交 `5f89c112` 的 CI 失败来自 Linux detect-secrets 对 SDK 测试固定假值的单个平台差异误报;已使用标准 inline allowlist 标记,安全规则本身未放宽,待当前提交触发 CI 重跑。 ## 生产阻断项 -### P0-1 远端发布候选验证 +### P0-1 远端发布候选验证(已完成) -- 部署当前代码到 `192.168.3.90` smoke。 -- 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。 -- 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。 -- 复跑两套 schema-check、资产精确核对、Docker smoke、核心接口压测和安全扫描。 +- [x] 部署当前代码到 `192.168.3.90` smoke。 +- [x] 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。 +- [x] 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。 +- [x] 复跑两套 schema-check、资产精确核对和 Docker smoke。 +- [ ] 当前提交 CI 全绿后,复跑带最新候选代码的接口压测与安全质量门,作为生产审批证据。 ### P0-2 真实 AI Provider 与质量验收 diff --git a/yuqei-ai-sdk-python/tests/test_ai_platform.py b/yuqei-ai-sdk-python/tests/test_ai_platform.py index b18e5b96..bbb73d6a 100644 --- a/yuqei-ai-sdk-python/tests/test_ai_platform.py +++ b/yuqei-ai-sdk-python/tests/test_ai_platform.py @@ -77,7 +77,7 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None: client = AiPlatformClient( "http://ai-platform.test", transport=httpx.MockTransport(handler), - service_key="ai-platform-service-key", + service_key="ai-platform-service-key", # pragma: allowlist secret ) response = client.run_legal_qa( diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page.tsx index 48b49bdb..115d6921 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page.tsx @@ -3,15 +3,17 @@ import { useEffect, useMemo, useState } from "react"; import { Building2, MailCheck, Plus, Save, Settings, ShieldCheck, UsersRound } from "lucide-react"; import { - getAccountSettings, - getIdentityRoles, - getIdentityTenants, - getIdentityUsers, type AccountSettings, type RolePermissionsPage, type TenantInfo, type TenantUserItem, } from "@/services/contracts"; +import { + getAccountSettings, + getIdentityRoles, + getIdentityTenants, + getIdentityUsers, +} from "@/services/contracts-client"; import { getRoleLabel, type UserRole } from "@/lib/access-control"; type TenantStatus = TenantInfo["status"]; diff --git a/yuqei-contract-platform/apps/contract-web/src/app/legal-qa/legal-qa-client.tsx b/yuqei-contract-platform/apps/contract-web/src/app/legal-qa/legal-qa-client.tsx index 52e9bef8..beb8ee2c 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/legal-qa/legal-qa-client.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/legal-qa/legal-qa-client.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { BookOpenCheck, ClipboardList, Loader2, MessageSquareText, RotateCcw, Send, ShieldCheck } from "lucide-react"; import { useMemo, useState } from "react"; -import { runLegalQa, type LegalQaCitation, type LegalQaResponse } from "@/services/contracts"; +import type { LegalQaCitation, LegalQaResponse } from "@/services/contracts"; +import { runLegalQa } from "@/services/contracts-client"; type LegalQaClientProps = { initialQuestion: string; diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts new file mode 100644 index 00000000..bac209bd --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts @@ -0,0 +1,58 @@ +import type { + AccountSettings, + LegalQaRequest, + LegalQaResponse, + RolePermissionsPage, + TenantPage, + TenantUserPage, +} from "@/services/contracts"; + +export async function runLegalQa(request: LegalQaRequest): Promise { + const response = await fetch("/api/contract-platform/legal/qa", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + question: request.question, + matter_context: request.matter_context || undefined, + knowledge_base_ids: request.knowledge_base_ids ?? [], + retrieval_strategy: request.retrieval_strategy ?? "hybrid", + }), + }); + if (!response.ok) { + const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null; + throw new Error(payload?.detail || payload?.message || "Legal QA request failed."); + } + return await response.json() as LegalQaResponse; +} + +export async function getIdentityTenants(): Promise { + const payload = await fetchClientJson("/api/contract-platform/identity/tenants"); + return payload ?? { items: [], total: 0 }; +} + +export async function getIdentityUsers(tenantId?: string): Promise { + const suffix = tenantId?.trim() ? `?tenant_id=${encodeURIComponent(tenantId.trim())}` : ""; + const payload = await fetchClientJson(`/api/contract-platform/identity/users${suffix}`); + return payload ?? { items: [], total: 0 }; +} + +export async function getIdentityRoles(): Promise { + const payload = await fetchClientJson("/api/contract-platform/identity/roles"); + return payload ?? { roles: [] }; +} + +export async function getAccountSettings(): Promise { + return fetchClientJson("/api/contract-platform/identity/account"); +} + +async function fetchClientJson(pathname: string): Promise { + try { + const response = await fetch(pathname, { cache: "no-store" }); + if (!response.ok) { + return null; + } + return await response.json() as T; + } catch { + return null; + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts index 5d1276ef..88a64855 100644 --- a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts @@ -1,5 +1,7 @@ import { contracts as fallbackContracts, tasks as fallbackTasks } from "@/lib/mock-data"; import { resolveCurrentRole, type UserRole } from "@/lib/access-control"; +import { fetchContractApi } from "@/lib/contract-api"; +import { getContractWebSession } from "@/lib/contract-session"; export type ContractListItem = { id: string; @@ -1276,8 +1278,15 @@ async function fetchJson( const timeout = controller ? setTimeout(() => controller.abort(), options.timeoutMs) : null; + const isServerRequest = typeof window === "undefined"; + const session = isServerRequest ? await getContractWebSession() : null; + if (isServerRequest && !session) { + return null; + } const url = resolveUrl(apiPathname, proxyPathname); - const cacheKey = typeof window === "undefined" && options.cacheTtlMs ? url : null; + const cacheKey = isServerRequest && options.cacheTtlMs + ? `${session?.tenant_id}:${session?.user_id}:${session?.role}:${url}` + : null; if (cacheKey) { const cached = serverFetchCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { @@ -1289,7 +1298,9 @@ async function fetchJson( cache: "no-store", signal: controller?.signal, }; - const response = await fetch(url, fetchInit); + const response = isServerRequest + ? await fetchContractApi(new URL(url), fetchInit) + : await fetch(url, fetchInit); if (!response.ok) { return null; } diff --git a/yuqei-infra/scripts/compare-v1-v2-assets.py b/yuqei-infra/scripts/compare-v1-v2-assets.py index 824327cf..616e0a7b 100644 --- a/yuqei-infra/scripts/compare-v1-v2-assets.py +++ b/yuqei-infra/scripts/compare-v1-v2-assets.py @@ -4,11 +4,12 @@ from __future__ import annotations import argparse import json +import os from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.error import URLError -from urllib.request import urlopen +from urllib.request import Request, urlopen REPO_ROOT = Path(__file__).resolve().parents[2] @@ -22,6 +23,10 @@ def main() -> int: parser.add_argument("--v1-root", type=Path, default=DEFAULT_V1_ROOT) parser.add_argument("--contract-api-base", default=DEFAULT_CONTRACT_API_BASE) parser.add_argument("--ai-platform-api-base", default=DEFAULT_AI_PLATFORM_API_BASE) + parser.add_argument("--tenant-id", default="default") + parser.add_argument("--user-id", default="user-current-admin") + parser.add_argument("--contract-proxy-key-env", default="CONTRACT_API_WEB_PROXY_SHARED_SECRET") + parser.add_argument("--ai-service-key-env", default="AI_PLATFORM_SERVICE_SHARED_SECRET") parser.add_argument( "--artifact-dir", type=Path, @@ -35,14 +40,34 @@ def main() -> int: stamp = checked_at.replace(":", "-").replace("+00:00", "Z") contract_api_base = args.contract_api_base.rstrip("/") ai_platform_api_base = args.ai_platform_api_base.rstrip("/") + contract_headers = identity_headers(args.tenant_id, args.user_id) + ai_platform_headers = identity_headers(args.tenant_id, args.user_id) + contract_proxy_key = os.getenv(args.contract_proxy_key_env, "").strip() + ai_service_key = os.getenv(args.ai_service_key_env, "").strip() + if contract_proxy_key: + contract_headers["X-Contract-Proxy-Key"] = contract_proxy_key + if ai_service_key: + ai_platform_headers["X-AI-Platform-Service-Key"] = ai_service_key - counts, parity = build_asset_report(args.v1_root, contract_api_base, ai_platform_api_base) + counts, parity = build_asset_report( + args.v1_root, + contract_api_base, + ai_platform_api_base, + contract_headers=contract_headers, + ai_platform_headers=ai_platform_headers, + ) result = { "status": derive_status(counts, parity), "checked_at": checked_at, "v1_root": str(args.v1_root), "contract_api_base": contract_api_base, "ai_platform_api_base": ai_platform_api_base, + "request_context": { + "tenant_id": args.tenant_id, + "user_id": args.user_id, + "contract_proxy_auth_configured": bool(contract_proxy_key), + "ai_service_auth_configured": bool(ai_service_key), + }, "counts": counts, "parity": parity, "notes": [ @@ -69,6 +94,9 @@ def build_asset_report( v1_root: Path, contract_api_base: str, ai_platform_api_base: str, + *, + contract_headers: dict[str, str] | None = None, + ai_platform_headers: dict[str, str] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: v1_phase13 = v1_root / "project-docs" / "phase13-legal-knowledge-template-library" v1_templates = v1_phase13 / "contract-templates" @@ -105,12 +133,12 @@ def build_asset_report( v2_prompt_keys = prompt_keys(read_json_file(v2_prompt_assets_file)) runtime_payloads = { - "templates": api_json(contract_api_base, "/api/v1/templates?page_size=100"), - "clauses": api_json(contract_api_base, "/api/v1/standard-clauses?page_size=100"), - "rules": api_json(contract_api_base, "/api/v1/review-rules?page_size=100"), - "legal_articles": api_json(contract_api_base, "/api/v1/legal-articles?page_size=1"), - "contracts": api_json(contract_api_base, "/api/v1/contracts?page_size=1"), - "prompts": api_json(ai_platform_api_base, "/api/v1/prompt-templates"), + "templates": api_json(contract_api_base, "/api/v1/templates?page_size=100", contract_headers), + "clauses": api_json(contract_api_base, "/api/v1/standard-clauses?page_size=100", contract_headers), + "rules": api_json(contract_api_base, "/api/v1/review-rules?page_size=100", contract_headers), + "legal_articles": api_json(contract_api_base, "/api/v1/legal-articles?page_size=1", contract_headers), + "contracts": api_json(contract_api_base, "/api/v1/contracts?page_size=1", contract_headers), + "prompts": api_json(ai_platform_api_base, "/api/v1/prompt-templates", ai_platform_headers), } runtime_template_ids = item_ids(runtime_payloads["templates"]) runtime_clause_ids = item_ids(runtime_payloads["clauses"]) @@ -212,9 +240,19 @@ def read_json_file(path: Path) -> Any: return {"__error__": f"Unable to read {path}: {exc}"} -def api_json(base_url: str, path: str) -> Any: +def identity_headers(tenant_id: str, user_id: str) -> dict[str, str]: + headers: dict[str, str] = {} + if tenant_id.strip(): + headers["X-Tenant-Id"] = tenant_id.strip() + if user_id.strip(): + headers["X-User-Id"] = user_id.strip() + return headers + + +def api_json(base_url: str, path: str, headers: dict[str, str] | None = None) -> Any: try: - with urlopen(f"{base_url}{path}", timeout=20) as response: + request = Request(f"{base_url}{path}", headers=headers or {}) + with urlopen(request, timeout=20) as response: return json.loads(response.read().decode("utf-8")) except (OSError, URLError, TimeoutError, json.JSONDecodeError) as exc: return {"__error__": f"{base_url}{path}: {exc}"}