新增第三方风险评估报告接口

This commit is contained in:
kx_guo 2026-10-27 16:08:41 +08:00
parent 79ddd8c8e7
commit 6995fe1f97
24 changed files with 2841 additions and 4 deletions

View File

@ -26,6 +26,17 @@ RISK_DINGTALK_APP_SECRET=
RISK_DINGTALK_CORP_ID= RISK_DINGTALK_CORP_ID=
RISK_DINGTALK_AGENT_ID= RISK_DINGTALK_AGENT_ID=
# ==================== 外部系统风险评估接口 ====================
# JSON 对象:调用系统编号 -> 独立 HMAC 密钥。默认 {} 禁止所有外部调用。
# 测试/正式环境使用不同密钥,密钥至少 32 个字符,仅保存在双方后端。
RISK_INTEGRATION_CLIENTS={}
# 已批准的非钉钉用户映射:调用系统 -> 对方账号标识 -> 本地 emp_no;默认不允许。
RISK_INTEGRATION_OPERATOR_MAPPINGS={}
# 示例:{"dieya-plm":{"superadmin":"plm_superadmin"}}
# 配置示例(请替换密钥):{"dieya-plm":"replace-with-at-least-32-random-characters"}
RISK_INTEGRATION_REQUESTS_PER_MINUTE=60
RISK_INTEGRATION_REPORT_TIMEOUT_SECONDS=110
# ==================== 百度 OCR ==================== # ==================== 百度 OCR ====================
# 图片调用 WebImage,PDF 调用 PaddleOCR-VL 文档解析;上传原件仅保存在本机目录。 # 图片调用 WebImage,PDF 调用 PaddleOCR-VL 文档解析;上传原件仅保存在本机目录。
BAIDU_OCR_API_KEY= BAIDU_OCR_API_KEY=

View File

@ -54,6 +54,11 @@ python scripts/clean_suppliers.py
风险评估预览:`POST http://127.0.0.1:18474/api/v1/risk-predictions/preview`。指标值表示风险程度(0-100),接口返回的综合分按 PRD 转换为安全得分,分数越高风险越低;等级阈值为 80/60/40/20。 风险评估预览:`POST http://127.0.0.1:18474/api/v1/risk-predictions/preview`。指标值表示风险程度(0-100),接口返回的综合分按 PRD 转换为安全得分,分数越高风险越低;等级阈值为 80/60/40/20。
外部系统风险报告:`POST /api/v1/integrations/risk-assessments/report` 使用 HMAC 签名鉴权,返回 Markdown 报告及 `X-Risk-Score`、`X-Risk-Level` 等响应头。`GET` 同路径按 `requestId` 查询已保存报告。启用前执行 `python -m alembic upgrade head`,配置 `RISK_INTEGRATION_CLIENTS` JSON 密钥表,并确保操作人已在本地绑定钉钉 UnionId 且拥有 `risk.assess` 操作权限;默认不自动创建用户。完整参数、GET/POST 签名、处理中状态与部署说明见 [外部接口文档](docs/外部系统风险评估报告接口.md)。
非钉钉操作人可用 `operator.externalUserId`,通过 `RISK_INTEGRATION_OPERATOR_MAPPINGS` 按调用系统显式映射到本地账号;仍校验本地状态及权限。开通工具:`python scripts/setup_external_risk.py allow-user --client-id dieya-plm --external-user-id superadmin --employee-no plm_superadmin --name "周子墨"`。该模式与钉钉身份二选一,配置后需重启后端。
对话页真实 AI:配置 `RISK_LLM_API_KEY`、`RISK_LLM_BASE_URL` 和 `RISK_LLM_MODEL` 后,调用 `POST /api/v1/conversations/{id}/stream`。接口使用 OpenAI 兼容 Chat Completions 协议,先提取 `intent/category/specification/packaging/supplier/factory/stage/confidence` 语义 JSON,再通过 SSE 返回增量文本;最近 20 条消息作为上下文,完整助手消息、语义结果和风险评分会在流结束后持久化。也兼容参考项目的 `OPENAI_API_KEY`、`LLM_BASE_URL`、`LLM_MODEL` 配置名。未配置密钥时返回明确的 503,不生成模拟回复。 对话页真实 AI:配置 `RISK_LLM_API_KEY`、`RISK_LLM_BASE_URL` 和 `RISK_LLM_MODEL` 后,调用 `POST /api/v1/conversations/{id}/stream`。接口使用 OpenAI 兼容 Chat Completions 协议,先提取 `intent/category/specification/packaging/supplier/factory/stage/confidence` 语义 JSON,再通过 SSE 返回增量文本;最近 20 条消息作为上下文,完整助手消息、语义结果和风险评分会在流结束后持久化。也兼容参考项目的 `OPENAI_API_KEY`、`LLM_BASE_URL`、`LLM_MODEL` 配置名。未配置密钥时返回明确的 503,不生成模拟回复。
架构说明见 [docs/architecture.md](docs/architecture.md)。 架构说明见 [docs/architecture.md](docs/architecture.md)。

View File

@ -6,6 +6,7 @@ from app.api.v1.auth import router as auth_router
from app.api.v1.conversations import router as conversations_router from app.api.v1.conversations import router as conversations_router
from app.api.v1.dashboard import router as dashboard_router from app.api.v1.dashboard import router as dashboard_router
from app.api.v1.health import router as health_router from app.api.v1.health import router as health_router
from app.api.v1.integration_risk import router as integration_risk_router
from app.api.v1.model_config import router as model_config_router from app.api.v1.model_config import router as model_config_router
from app.api.v1.monitoring import router as monitoring_router from app.api.v1.monitoring import router as monitoring_router
from app.api.v1.ocr import router as ocr_router from app.api.v1.ocr import router as ocr_router
@ -17,6 +18,7 @@ from app.api.v1.suppliers import router as suppliers_router
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health_router) api_router.include_router(health_router)
api_router.include_router(integration_risk_router)
api_router.include_router(model_config_router) api_router.include_router(model_config_router)
api_router.include_router(monitoring_router) api_router.include_router(monitoring_router)
api_router.include_router(ocr_router) api_router.include_router(ocr_router)

View File

@ -0,0 +1,218 @@
"""Signed external assessments and report lookup; no browser JWT or conversation required."""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from fastapi.routing import APIRoute
from pydantic import TypeAdapter, ValidationError
from sqlalchemy.exc import SQLAlchemyError
from app.api.dependencies import DatabaseSession
from app.core.config import get_settings
from app.core.exceptions import AppException
from app.models.entities import IntegrationAssessment, OperationAudit
from app.repositories.integration_risk import IntegrationRiskRepository
from app.schemas.integration_risk import IntegrationOperator, IntegrationRiskRequest, RequestId
from app.services.external_risk_report import ExternalRiskReportService
from app.services.integration_auth import authenticate_integration, authorize_operator
from app.services.markdown_reports import render_markdown_report
logger = logging.getLogger(__name__)
class IntegrationRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def wrapped(request: Request):
try:
return await handler(request)
except AppException as exc:
headers = {"Retry-After": "60"} if exc.status_code == 429 else {}
return JSONResponse(
status_code=exc.status_code,
headers=headers,
content={
"code": exc.code,
"message": exc.message,
"data": exc.details,
},
)
except SQLAlchemyError:
logger.error("External report database operation failed")
return JSONResponse(
status_code=503,
content={
"code": 503,
"message": "风险数据库暂不可用,请使用原请求编号重试",
"data": None,
},
)
except RequestValidationError as exc:
invalid_json = any(error["type"] == "json_invalid" for error in exc.errors())
status = 400 if invalid_json else 422
locations = [".".join(map(str, error["loc"])) for error in exc.errors()]
return JSONResponse(
status_code=status,
content={
"code": status,
"message": "请求体必须是合法 JSON"
if invalid_json
else "请求参数不符合约束:" + "、".join(locations),
"data": None,
},
)
except Exception as exc: # noqa: BLE001 - external callers receive no internal traceback
logger.error("External interface failed: %s", exc.__class__.__name__)
return JSONResponse(
status_code=500,
content={
"code": 500,
"message": "平台内部处理异常,请使用原请求编号重试",
"data": None,
},
)
return wrapped
router = APIRouter(prefix="/integrations/risk-assessments", tags=["integrations"], route_class=IntegrationRoute)
async def integration_client(request: Request, session: DatabaseSession) -> str:
if (
request.method == "POST"
and request.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json"
):
raise AppException("Content-Type 必须为 application/json", code=400, status_code=400)
if len(await request.body()) > 65536:
raise AppException("请求体超过 64KB", code=422, status_code=422)
return await authenticate_integration(request, session)
ClientId = Annotated[str, Depends(integration_client)]
def report_response(record: IntegrationAssessment) -> Response:
if record.status == "processing":
return JSONResponse(
status_code=202,
headers={"Retry-After": "5"},
content={
"code": 202,
"message": "报告正在生成,请稍后查询",
"data": {"requestId": record.request_id},
},
)
if record.status == "failed":
raise AppException("报告生成失败,请使用原请求编号和内容重新提交", code=503, status_code=503)
result = record.result_data
return Response(
content=record.report_markdown,
media_type="text/markdown; charset=utf-8",
headers={
"X-Request-Id": record.request_id,
"X-Assessment-Id": str(record.id),
"X-Model-Version": result["modelVersion"],
"X-Risk-Score": f"{result['score']:.2f}",
"X-Risk-Level": result["level"],
"Cache-Control": "no-store",
},
)
@router.post(
"/report",
response_class=Response,
responses={
200: {"description": "Markdown 报告", "content": {"text/markdown": {"schema": {"type": "string"}}}},
202: {"description": "同一请求正在生成"},
},
)
async def create_external_report(
request: Request,
payload: IntegrationRiskRequest,
session: DatabaseSession,
client_id: ClientId,
) -> Response:
if payload.source_system != client_id:
raise AppException("sourceSystem 必须与 X-Client-Id 一致", code=403, status_code=403)
if request.headers.get("Idempotency-Key") != payload.request_id:
raise AppException("Idempotency-Key 必须与 requestId 一致", code=422, status_code=422)
today = datetime.now(timezone(timedelta(hours=8))).date()
if payload.assessment.as_of_date and payload.assessment.as_of_date > today:
raise AppException("asOfDate 不允许使用未来日期", code=422, status_code=422)
user = await authorize_operator(session, payload.operator, client_id)
user_id, operator_name, emp_no = user.id, user.name, user.emp_no
repository = IntegrationRiskRepository(session)
record, owned = await repository.claim(payload, user_id, today)
if not owned:
return report_response(record)
record_id, token, as_of = record.id, record.owner_token, record.as_of_date
try:
async with asyncio.timeout(get_settings().integration_report_timeout_seconds):
result = await ExternalRiskReportService(session).assess(payload, as_of)
markdown = render_markdown_report(payload, result, as_of, operator_name)
session.add(
OperationAudit(
actor_user_id=user_id,
actor_emp_no=emp_no,
action="integration.risk_assess",
resource_type="integration_assessment",
resource_id=str(record_id),
audit_metadata={
"clientId": client_id,
"requestId": payload.request_id,
"modelVersion": result.model_version,
},
)
)
if not await repository.finish(
record_id, token, result=result.model_dump(mode="json", by_alias=True), markdown=markdown
):
raise AppException("评估处理权已变更,请查询原请求", code=503, status_code=503)
except asyncio.CancelledError:
await session.rollback()
await repository.finish(record_id, token, error="请求处理被中断")
raise
except Exception as exc: # noqa: BLE001 - preserve retryable failure without exposing database/provider errors
logger.warning("External assessment failed request=%s error=%s", payload.request_id, exc.__class__.__name__)
await session.rollback()
await repository.finish(record_id, token, error="报告生成失败")
raise AppException("报告生成失败,请使用原请求编号和内容重试", code=503, status_code=503) from exc
record = await repository.get(client_id, payload.request_id)
return report_response(record)
@router.get(
"/report",
response_class=Response,
openapi_extra={
"parameters": [
{"name": "requestId", "in": "query", "required": True, "schema": {"type": "string", "maxLength": 128}}
],
"responses": {
"200": {"description": "原始 Markdown 报告", "content": {"text/markdown": {"schema": {"type": "string"}}}},
"202": {"description": "报告正在生成"},
},
},
)
async def get_external_report(request: Request, session: DatabaseSession, client_id: ClientId) -> Response:
if list(request.query_params.keys()) != ["requestId"] or len(request.query_params.getlist("requestId")) != 1:
raise AppException("查询参数必须且只能包含一个 requestId", code=422, status_code=422)
if await request.body():
raise AppException("报告查询不能携带请求体", code=400, status_code=400)
try:
request_id = TypeAdapter(RequestId).validate_python(request.query_params["requestId"])
except ValidationError as exc:
raise AppException("requestId 格式不符合约束", code=422, status_code=422) from exc
record = await IntegrationRiskRepository(session).get(client_id, request_id)
if record is None:
raise AppException("当前调用系统未找到该请求", code=404, status_code=404)
await authorize_operator(session, IntegrationOperator.model_validate(record.request_data["operator"]), client_id)
return report_response(record)

View File

@ -25,6 +25,10 @@ class Settings(BaseSettings):
dingtalk_app_secret: SecretStr | None = Field(default=None, repr=False) dingtalk_app_secret: SecretStr | None = Field(default=None, repr=False)
dingtalk_corp_id: str = "" dingtalk_corp_id: str = ""
dingtalk_agent_id: str = "" dingtalk_agent_id: str = ""
integration_clients: SecretStr = Field(default=SecretStr("{}"), repr=False)
integration_operator_mappings: SecretStr = Field(default=SecretStr("{}"), repr=False)
integration_requests_per_minute: int = Field(default=60, ge=1, le=10000)
integration_report_timeout_seconds: int = Field(default=110, ge=10, le=110)
baidu_ocr_api_key: SecretStr | None = Field( baidu_ocr_api_key: SecretStr | None = Field(
default=None, default=None,
repr=False, repr=False,

View File

@ -5,6 +5,8 @@ from app.models.entities import (
AnomalyCaseSupplier, AnomalyCaseSupplier,
Conversation, Conversation,
ConversationMessage, ConversationMessage,
IntegrationAssessment,
IntegrationNonce,
OperationAudit, OperationAudit,
PurchaseOrderLine, PurchaseOrderLine,
ReviewRecord, ReviewRecord,
@ -13,8 +15,8 @@ from app.models.entities import (
RiskModelConfig, RiskModelConfig,
RolePermission, RolePermission,
StandardRule, StandardRule,
SupplierProfile,
SupplierMaster, SupplierMaster,
SupplierProfile,
UserAccount, UserAccount,
) )
@ -23,6 +25,8 @@ __all__ = [
"AnomalyCaseSupplier", "AnomalyCaseSupplier",
"Conversation", "Conversation",
"ConversationMessage", "ConversationMessage",
"IntegrationAssessment",
"IntegrationNonce",
"OperationAudit", "OperationAudit",
"ReviewRecord", "ReviewRecord",
"RiskAssessment", "RiskAssessment",

View File

@ -4,7 +4,20 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, UniqueConstraint, func from sqlalchemy import (
BigInteger,
Boolean,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -106,7 +119,9 @@ class AnomalyCaseSupplier(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
case_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("anomaly_cases.id", ondelete="CASCADE"), nullable=False) case_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("anomaly_cases.id", ondelete="CASCADE"), nullable=False)
supplier_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("supplier_master.id", ondelete="CASCADE"), nullable=False) supplier_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("supplier_master.id", ondelete="CASCADE"), nullable=False
)
supplier_role: Mapped[str | None] = mapped_column(String(50)) supplier_role: Mapped[str | None] = mapped_column(String(50))
material_type: Mapped[str | None] = mapped_column(String(200)) material_type: Mapped[str | None] = mapped_column(String(200))
source_text: Mapped[str | None] = mapped_column(Text) source_text: Mapped[str | None] = mapped_column(Text)
@ -252,6 +267,51 @@ class ConversationMessage(Base):
conversation: Mapped[Conversation] = relationship(back_populates="messages") conversation: Mapped[Conversation] = relationship(back_populates="messages")
class IntegrationNonce(Base):
"""Durable replay protection shared by all API workers."""
__tablename__ = "integration_nonces"
__table_args__ = (
UniqueConstraint("client_id", "nonce", name="uq_integration_nonce"),
Index("ix_integration_nonce_expires", "expires_at"),
Index("ix_integration_nonce_client_created", "client_id", "created_at"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
client_id: Mapped[str] = mapped_column(String(64), nullable=False)
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class IntegrationAssessment(Base):
"""Immutable successful reports and recoverable requests from external systems."""
__tablename__ = "integration_assessments"
__table_args__ = (
UniqueConstraint("client_id", "request_id", name="uq_integration_assessment_request"),
Index("ix_integration_assessment_user", "user_id"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
client_id: Mapped[str] = mapped_column(String(64), nullable=False)
request_id: Mapped[str] = mapped_column(String(128), nullable=False)
# Logical reference, like OperationAudit: migration does not require
# REFERENCES on the pre-existing user table, and reports survive account removal.
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
request_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
as_of_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
owner_token: Mapped[str] = mapped_column(String(32), nullable=False)
lease_until: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
result_data: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
report_markdown: Mapped[str | None] = mapped_column(Text)
error_message: Mapped[str | None] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class RiskItem(Base): class RiskItem(Base):
"""Risk item submitted through form, voice, OCR, or import before approval.""" """Risk item submitted through form, voice, OCR, or import before approval."""

View File

@ -0,0 +1,120 @@
"""Atomic idempotency claims with a lease and fenced completion."""
import hashlib
import json
import uuid
from datetime import UTC, date, datetime, timedelta
from sqlalchemy import or_, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import AppException
from app.models.entities import IntegrationAssessment
from app.schemas.integration_risk import IntegrationRiskRequest
class IntegrationRiskRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def get(self, client_id: str, request_id: str) -> IntegrationAssessment | None:
return await self.session.scalar(
select(IntegrationAssessment)
.where(
IntegrationAssessment.client_id == client_id,
IntegrationAssessment.request_id == request_id,
)
.execution_options(populate_existing=True)
)
async def claim(
self,
payload: IntegrationRiskRequest,
user_id: int,
default_date: date,
) -> tuple[IntegrationAssessment, bool]:
data = payload.model_dump(mode="json", by_alias=True)
# Preserve the digest of DingTalk requests created before externalUserId was introduced.
if data["operator"]["externalUserId"] is None:
data["operator"].pop("externalUserId")
digest = hashlib.sha256(
json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
now = datetime.now(UTC)
token = uuid.uuid4().hex
lease_until = now + timedelta(seconds=150)
inserted = await self.session.scalar(
insert(IntegrationAssessment)
.values(
client_id=payload.source_system,
request_id=payload.request_id,
user_id=user_id,
request_hash=digest,
request_data=data,
as_of_date=payload.assessment.as_of_date or default_date,
status="processing",
owner_token=token,
lease_until=lease_until,
created_at=now,
updated_at=now,
)
.on_conflict_do_nothing(index_elements=["client_id", "request_id"])
.returning(IntegrationAssessment.id)
)
record = await self.get(payload.source_system, payload.request_id)
if record is None:
raise AppException("评估请求记录失败", code=503, status_code=503)
if record.request_hash != digest:
raise AppException("requestId 已被不同请求内容使用", code=409, status_code=409)
if inserted is not None:
await self.session.commit()
return record, True
if record.status == "completed":
return record, False
reclaimed = await self.session.scalar(
update(IntegrationAssessment)
.where(
IntegrationAssessment.id == record.id,
or_(IntegrationAssessment.status == "failed", IntegrationAssessment.lease_until <= now),
IntegrationAssessment.status != "completed",
)
.values(status="processing", owner_token=token, lease_until=lease_until, error_message=None, updated_at=now)
.execution_options(synchronize_session=False)
.returning(IntegrationAssessment.id)
)
await self.session.commit()
record = await self.get(payload.source_system, payload.request_id)
return record, reclaimed is not None
async def finish(
self,
record_id: int,
owner_token: str,
*,
result: dict | None = None,
markdown: str | None = None,
error: str | None = None,
) -> bool:
updated = await self.session.scalar(
update(IntegrationAssessment)
.where(
IntegrationAssessment.id == record_id,
IntegrationAssessment.owner_token == owner_token,
IntegrationAssessment.status == "processing",
)
.values(
status="failed" if error else "completed",
result_data=result,
report_markdown=markdown,
error_message=error,
updated_at=datetime.now(UTC),
)
.execution_options(synchronize_session=False)
.returning(IntegrationAssessment.id)
)
if updated is None:
await self.session.rollback()
return False
await self.session.commit()
return True

View File

@ -0,0 +1,61 @@
"""Validated external assessment inputs; no caller-supplied scores or roles."""
from datetime import date
from typing import Annotated, Literal
from pydantic import ConfigDict, Field, StringConstraints, field_validator, model_validator
from app.schemas.common import ApiSchema
class IntegrationSchema(ApiSchema):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class IntegrationOperator(IntegrationSchema):
dingtalk_union_id: str | None = Field(default=None, min_length=1, max_length=64)
external_user_id: str | None = Field(default=None, min_length=1, max_length=128)
dingtalk_user_id: str | None = Field(default=None, min_length=1, max_length=50)
employee_no: str | None = Field(default=None, max_length=64)
name: str | None = Field(default=None, max_length=50)
@model_validator(mode="after")
def identity_source(self):
if bool(self.dingtalk_union_id) == bool(self.external_user_id):
raise ValueError("dingtalkUnionId 和 externalUserId 必须且只能提供一个")
if self.external_user_id and self.dingtalk_user_id:
raise ValueError("外部账号模式不能同时提供 dingtalkUserId")
return self
class IntegrationProduct(IntegrationSchema):
subject_id: str = Field(min_length=1, max_length=128)
product_name: str | None = Field(default=None, max_length=200)
product_category: str = Field(min_length=1, max_length=100)
specification: str | None = Field(default=None, max_length=100)
material_type: str = Field(min_length=1, max_length=200)
supplier: str | None = Field(default=None, max_length=300)
factory: str | None = Field(default=None, max_length=300)
stage_new: Literal["需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理"] | None = None
defect_level: Literal["A", "B", "C"] | None = None
description: str | None = Field(default=None, max_length=2000)
as_of_date: date | None = None
@field_validator("as_of_date", mode="before")
@classmethod
def iso_date(cls, value): # noqa: ANN001
if value is not None and (not isinstance(value, str) or len(value) != 10):
raise ValueError("asOfDate 必须为 YYYY-MM-DD")
if value is not None and date.fromisoformat(value).year < 1900:
raise ValueError("asOfDate 不早于 1900-01-01")
return value
RequestId = Annotated[str, StringConstraints(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$")]
class IntegrationRiskRequest(IntegrationSchema):
request_id: RequestId
source_system: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
operator: IntegrationOperator
assessment: IntegrationProduct

View File

@ -0,0 +1,217 @@
"""Assess structured product data without creating conversations or re-parsing fields with an LLM."""
import asyncio
import logging
from datetime import date
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.entities import AnomalyCase, StandardRule
from app.schemas.integration_risk import IntegrationRiskRequest
from app.schemas.risk_prediction import (
CategoryWarning,
RiskAssessmentResult,
RiskDimensionScore,
RiskFactorResult,
StandardJudgment,
SupplierFrequency,
)
from app.services.database_risk_scoring import (
DatabaseRiskScoringService,
normalize_category,
normalize_material,
)
from app.services.llm_client import LLMClient
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
from app.services.rag import AnomalyCaseRAG
from app.services.supplier_identity import resolve_supplier
logger = logging.getLogger(__name__)
class ExternalRiskReportService:
def __init__(self, session: AsyncSession):
self.session = session
async def assess(self, payload: IntegrationRiskRequest, as_of: date) -> RiskAssessmentResult:
product = payload.assessment
start, _ = calendar_year_window(as_of, 3)
# Bound both scoring and case retrieval by the supplied cutoff date.
fields = (
AnomalyCase.id,
AnomalyCase.date,
AnomalyCase.product_name,
AnomalyCase.product_category,
AnomalyCase.material_type,
AnomalyCase.supplier,
AnomalyCase.factory,
AnomalyCase.stage_new,
AnomalyCase.defect_level,
AnomalyCase.anomaly_type,
AnomalyCase.description,
AnomalyCase.root_cause,
AnomalyCase.solution,
AnomalyCase.result,
)
rows = [
dict(row)
for row in (
await self.session.execute(
select(*fields).where(
AnomalyCase.date >= start,
AnomalyCase.date <= as_of,
)
)
)
.mappings()
.all()
]
identity = await resolve_supplier(self.session, product.supplier)
supplier = identity.canonical_name if identity else product.supplier
cooperation = await supplier_order_counts(self.session, supplier, start, as_of) if supplier else 0
score = await DatabaseRiskScoringService(self.session, as_of=as_of).score(
category=product.product_category,
material=product.material_type,
supplier=supplier,
factory=product.factory,
stage=product.stage_new,
defect_level=product.defect_level,
rows=rows,
cooperation_count=cooperation or 0,
)
query = ",".join(
value
for value in (
product.product_name,
product.product_category,
product.material_type,
supplier,
product.factory,
product.stage_new,
product.description,
)
if value
)
retrieved = await AnomalyCaseRAG(self.session).search(
query=query,
category=product.product_category,
material=product.material_type,
supplier=supplier,
factory=product.factory,
stage=product.stage_new,
rows=rows,
)
category = normalize_category(product.product_category)
material = normalize_material(product.material_type)
terms = list(
dict.fromkeys(
value
for value in (
product.product_category,
*(category or "").split("/"),
material,
*(term for term in ("PET", "PP", "软管", "泵头", "瓶", "纸盒", "密封", "相容性") if term in query),
)
if value
)
)
clauses = [
column.ilike(f"%{term}%")
for term in terms
for column in (
StandardRule.category_lv1,
StandardRule.category_lv2,
StandardRule.category_lv3,
StandardRule.risk_point,
)
]
standard_rows = list(
(
await self.session.scalars(
select(StandardRule)
.where(
StandardRule.status == "启用",
or_(*clauses),
)
.order_by(StandardRule.id)
.limit(6)
)
).all()
)
standards = [
StandardJudgment(
category=row.category_lv1,
risk_point=row.risk_point,
rule=row.judge_standard,
source=row.regulation or "标准文件.xlsx",
status="待检测",
)
for row in standard_rows
]
dimensions = [
RiskDimensionScore(
dimension=item.name,
score=item.score,
weight=item.weight,
status=item.status,
reason=item.reason,
sample_size=item.sample_size,
raw_value=item.raw_value,
)
for item in score.dimensions
]
category_dimension = next(item for item in score.dimensions if item.name == "产品品类")
supplier_dimension = next(item for item in score.dimensions if item.name == "供应商表现")
result = RiskAssessmentResult(
subject_id=product.subject_id,
score=score.score,
level=score.level,
model_version="database-seven-dimension-v1",
factors=[
RiskFactorResult(
name=item.name,
value=round(100 - item.score, 2),
weight=item.weight,
contribution=round(item.score * item.weight / 100, 2),
)
for item in score.dimensions
],
dimension_scores=dimensions,
category_warning=CategoryWarning(
category=category or "无法归类",
anomaly_count=category_dimension.sample_size or 0,
risk_level="案例较少" if (category_dimension.sample_size or 0) < 10 else score.level.value,
suggestion=category_dimension.reason,
),
supplier_freq=SupplierFrequency(
supplier_name=supplier,
cooperation_count=cooperation if cooperation else None,
anomaly_count=supplier_dimension.sample_size,
alpha=supplier_dimension.frequency_alpha,
frequency_basis=f"{start} 至 {as_of} 采购订单去重单据数",
note=supplier_dimension.reason,
data_available=bool(cooperation),
),
standard_judgment=standards,
similar_cases=[item.case for item in retrieved],
)
fallback = [
"打样阶段完成内容物与包装材料相容性、密封性及加速老化验证。",
"结合历史异常和命中标准制定来料检验、过程巡检和首批试产验证清单。",
"补充供应商、加工厂及实际检测数据,由品控复核默认分维度后再作放行决策。",
"对验证项明确责任人、完成节点及验收依据,并将结果回填形成闭环。",
]
try:
async with asyncio.timeout(20):
result.ai_suggestions = await LLMClient().generate_prevention_suggestions(
{
"input": product.model_dump(mode="json", by_alias=True),
"assessment": result.model_dump(mode="json", by_alias=True),
}
)
except Exception as exc: # noqa: BLE001 - rules and actual scores remain usable without the LLM
logger.warning("External report suggestions unavailable: %s", exc.__class__.__name__)
result.ai_suggestions = fallback
result.alert_text = "AI 建议服务不可用,本报告使用固定验证建议;评分仍来自数据库及规则。"
return result

View File

@ -0,0 +1,168 @@
"""System HMAC authentication, durable replay protection and DingTalk authorization."""
import base64
import hashlib
import hmac
import json
import re
from datetime import UTC, datetime, timedelta
from fastapi import Request
from sqlalchemy import delete, func, select, text
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.exceptions import AppException
from app.models.entities import IntegrationNonce, UserAccount
from app.repositories.users import UserRepository
from app.schemas.integration_risk import IntegrationOperator
def signature_message(request: Request, timestamp: str, nonce: str, body: bytes) -> bytes:
prefix = timestamp.encode("ascii") + b"\n" + nonce.encode("ascii") + b"\n"
if request.method == "GET":
# GET binds both the endpoint and query so a signature cannot be moved to another report.
target = request.scope.get("raw_path", request.url.path.encode("ascii"))
query = request.scope.get("query_string", b"")
return b"GET\n" + target + b"\n" + query + b"\n" + prefix
return prefix + body
async def authenticate_integration(request: Request, session: AsyncSession) -> str:
settings = get_settings()
client_id = request.headers.get("X-Client-Id", "")
timestamp = request.headers.get("X-Timestamp", "")
nonce = request.headers.get("X-Nonce", "")
signature = request.headers.get("X-Signature", "")
if not all((client_id, timestamp, nonce, signature)):
raise AppException("缺少系统签名认证信息", code=401, status_code=401)
try:
clients = json.loads(settings.integration_clients.get_secret_value())
if not isinstance(clients, dict) or any(
not isinstance(key, str)
or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key)
or not isinstance(value, str)
or len(value) < 32
for key, value in clients.items()
):
raise ValueError
except (ValueError, TypeError) as exc:
raise AppException("外部系统鉴权配置无效", code=503, status_code=503) from exc
secret = clients.get(client_id)
if secret is None:
raise AppException("调用系统未开通接口", code=403, status_code=403)
if not re.fullmatch(r"[0-9]{1,12}", timestamp) or not re.fullmatch(r"[A-Za-z0-9_-]{8,64}", nonce):
raise AppException("时间戳或随机串格式错误", code=401, status_code=401)
now = datetime.now(UTC)
if abs(now.timestamp() - int(timestamp)) > 300:
raise AppException("系统签名时间戳已过期", code=401, status_code=401)
expected = base64.b64encode(
hmac.new(
secret.encode("utf-8"), signature_message(request, timestamp, nonce, await request.body()), hashlib.sha256
).digest()
).decode("ascii")
if not re.fullmatch(r"[A-Za-z0-9+/]{43}=", signature) or not hmac.compare_digest(signature, expected):
raise AppException("系统签名校验失败", code=401, status_code=401)
# Serialize only the short authentication transaction for this client across workers.
lock_key = int.from_bytes(hashlib.sha256(f"integration-auth:{client_id}".encode()).digest()[:8], signed=True)
await session.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key})
await session.execute(delete(IntegrationNonce).where(IntegrationNonce.expires_at <= now))
replay = await session.scalar(
select(IntegrationNonce.id).where(
IntegrationNonce.client_id == client_id,
IntegrationNonce.nonce == nonce,
)
)
if replay is not None:
raise AppException("随机串已使用,请重新签名", code=401, status_code=401)
count = await session.scalar(
select(func.count())
.select_from(IntegrationNonce)
.where(
IntegrationNonce.client_id == client_id,
IntegrationNonce.created_at > now - timedelta(seconds=60),
)
)
if count >= settings.integration_requests_per_minute:
raise AppException("超过系统调用频率限制", code=429, status_code=429)
await session.execute(
insert(IntegrationNonce).values(
client_id=client_id,
nonce=nonce,
created_at=now,
expires_at=now + timedelta(minutes=10),
)
)
# Persist nonce even if body validation or assessment later fails.
await session.commit()
return client_id
async def authorize_operator(
session: AsyncSession,
operator: IntegrationOperator,
client_id: str | None = None,
) -> UserAccount:
if operator.external_user_id:
if not client_id:
raise AppException("外部账号必须关联已认证的调用系统", code=403, status_code=403)
try:
mappings = json.loads(get_settings().integration_operator_mappings.get_secret_value())
if not isinstance(mappings, dict) or any(
not isinstance(client, str)
or not isinstance(accounts, dict)
or any(
not isinstance(external, str)
or not external
or not isinstance(local, str)
or not local
or len(local) > 20
for external, local in accounts.items()
)
for client, accounts in mappings.items()
):
raise ValueError
except (ValueError, TypeError) as exc:
raise AppException("外部账号映射配置无效", code=503, status_code=503) from exc
local_employee_no = mappings.get(client_id, {}).get(operator.external_user_id)
if not local_employee_no:
raise AppException("外部用户未在当前调用系统开通", code=403, status_code=403)
users = list(
(
await session.scalars(
select(UserAccount)
.where(
UserAccount.emp_no == local_employee_no,
)
.limit(2)
)
).all()
)
else:
users = list(
(
await session.scalars(
select(UserAccount)
.where(
UserAccount.dingtalk_union_id == operator.dingtalk_union_id,
)
.limit(2)
)
).all()
)
if len(users) != 1:
kind = "外部" if operator.external_user_id else "钉钉"
message = f"{kind}用户未在风险平台开通" if not users else f"{kind}用户映射不唯一,请联系管理员"
raise AppException(message, code=403, status_code=403)
user = users[0]
if user.status != "启用":
raise AppException("用户已停用", code=403, status_code=403)
if operator.dingtalk_user_id and operator.dingtalk_user_id != user.dingtalk_id:
raise AppException("钉钉 UnionId 与 UserId 不一致", code=403, status_code=403)
if user.role != "admin":
permissions = await UserRepository(session).permissions_for_role(user.role, operate=True)
if not any(item.func_key == "risk.assess" for item in permissions):
raise AppException("用户无风险评估权限", code=403, status_code=403)
return user

View File

@ -0,0 +1,140 @@
"""Deterministic Markdown reports with escaped untrusted text and explicit data boundaries."""
import html
from datetime import date
from app.schemas.integration_risk import IntegrationRiskRequest
from app.schemas.risk_prediction import RiskAssessmentResult
from app.services.database_risk_scoring import normalize_category, normalize_material
from app.services.purchase_orders import calendar_year_window
from app.services.reports import LEVEL_LABELS
def md_text(value: object) -> str:
if value is None or value == "":
return "未提供"
value = html.escape(str(value), quote=False).replace("\r", " ").replace("\n", " ")
for char in ("\\", "`", "*", "_", "[", "]", "|", "#", "!"):
value = value.replace(char, "\\" + char)
return value
def render_markdown_report(
payload: IntegrationRiskRequest,
result: RiskAssessmentResult,
as_of: date,
operator_name: str,
) -> str:
product = payload.assessment
start, _ = calendar_year_window(as_of, 3)
info = (
("评估对象", product.product_name or product.subject_id),
("对象编号", product.subject_id),
("请求编号", payload.request_id),
("提交品类", product.product_category),
("评分品类", normalize_category(product.product_category) or "无法归类,品类维度使用默认 50 分"),
("规格", product.specification),
("包装原文", product.material_type),
("评分物料标签", normalize_material(product.material_type)),
("供应商", product.supplier),
("加工厂", product.factory),
("开发阶段", product.stage_new),
("已知缺陷等级", product.defect_level),
("评估人", operator_name),
("数据截止日期", as_of),
("评估背景", product.description),
)
lines = ["# 新品风险评估报告", "", "## 一、评估信息", "", "| 项目 | 内容 |", "|---|---|"]
lines.extend(f"| {label} | {md_text(value)} |" for label, value in info)
lines.extend(
[
"",
"## 二、综合风险结论",
"",
f"- 综合安全得分:{result.score:.2f}(越高风险越低)",
f"- 风险等级:{LEVEL_LABELS[result.level.value]}",
f"- 模型版本:{md_text(result.model_version)}",
]
)
if result.alert_text:
lines.append(f"- 数据与建议提示:{md_text(result.alert_text)}")
lines.extend(
[
"",
"## 三、七维风险评分",
"",
"| 风险维度 | 得分 | 权重 | 数据状态 | 说明 |",
"|---|---:|---:|---|---|",
]
)
lines.extend(
f"| {md_text(item.dimension)} | {item.score:.2f} | {item.weight:g}% | "
f"{md_text(item.status)} | {md_text(item.reason)} |"
for item in result.dimension_scores
)
lines.extend(["", "## 四、历史统计与相似异常案例", ""])
if result.category_warning:
lines.append(f"- 评分品类历史异常:{result.category_warning.anomaly_count} 条。")
if result.supplier_freq:
freq = result.supplier_freq
lines.extend(
[
f"- 供应商:{md_text(freq.supplier_name)};合作次数:{md_text(freq.cooperation_count)};"
f"历史异常:{md_text(freq.anomaly_count)}。",
f"- 供应商统计说明:{md_text(freq.note)}",
]
)
lines.extend(["", "| 日期 | 产品 | 异常现象 | 原因 | 历史处理方案 | 相似度 |", "|---|---|---|---|---|---:|"])
for case in result.similar_cases:
similarity = f"{case.similarity:.1%}" if case.similarity is not None else "未提供"
lines.append(
"| "
+ " | ".join(
md_text(value)
for value in (
case.date,
case.product,
case.problem or case.description,
case.root_cause,
case.solution,
similarity,
)
)
+ " |"
)
if not result.similar_cases:
lines.extend(["", "暂无匹配的历史案例,不能据此认定产品无风险。"])
lines.extend(["", "## 五、标准规则", "", "| 风险点 | 判定规则 | 来源 | 状态 |", "|---|---|---|---|"])
lines.extend(
"| "
+ " | ".join(
md_text(value)
for value in (
rule.risk_point,
rule.rule,
rule.source,
rule.status,
)
)
+ " |"
for rule in result.standard_judgment
)
if not result.standard_judgment:
lines.extend(["", "暂无匹配标准,需由品控补充核验;不代表已通过合规或检测判定。"])
lines.extend(["", "## 六、风险预防建议", ""])
lines.extend(f"{index}. {md_text(value)}" for index, value in enumerate(result.ai_suggestions, 1))
lines.extend(
[
"",
"## 七、数据口径说明",
"",
f"- 数据来自风险平台本地异常案例、采购订单及启用的标准规则;统计区间为 {start} 至 {as_of}。",
"- 沿用现有评分口径,以截止年份往前 3 年的 1 月 1 日为区间起点。",
"- 包装组合沿用现有关键词归一化规则,只使用一个匹配物料标签进行评分。",
"- 缺少巡检、检测结果或可用历史记录的维度按现有默认分处理,并在明细中标注。",
"- 缺陷维度基于历史 A/B/C 记录计算,不预测新品实际缺陷等级。",
"- 标准匹配和预防建议不替代实际检测与品控审核;最终放行由业务和品控人员确认。",
"",
]
)
return "\n".join(lines)

View File

@ -53,6 +53,7 @@ class AnomalyCaseRAG:
factory: str | None = None, factory: str | None = None,
stage: str | None = None, stage: str | None = None,
anomaly_type: str | None = None, anomaly_type: str | None = None,
rows: list[dict[str, Any]] | None = None,
) -> list[RetrievedCase]: ) -> list[RetrievedCase]:
identity = await resolve_supplier(self.session, supplier) identity = await resolve_supplier(self.session, supplier)
supplier_match = ( supplier_match = (
@ -61,7 +62,7 @@ class AnomalyCaseRAG:
else supplier else supplier
) )
terms = self._terms(query, category, material, supplier, factory, stage, anomaly_type) terms = self._terms(query, category, material, supplier, factory, stage, anomaly_type)
rows = await self._load_rows( rows = rows if rows is not None else await self._load_rows(
query, query,
category=category, category=category,
material=material, material=material,

View File

@ -0,0 +1,53 @@
# PLM 接口测试脚本使用说明
脚本 `scripts/test_plm_risk_report.py` 使用对方 2026-10-09 调用说明中第 3 节的完整参数。
样例保存在 `docs/examples/plm-risk-request-20261009.json`,品类保持“护发素”,不改为洗发水。
## 原报告与幂等验证
在后端项目根目录执行:
```powershell
.\.venv\Scripts\python.exe scripts/test_plm_risk_report.py --local-config
```
默认访问 `http://192.168.9.28:18474/api/v1/integrations/risk-assessments/report`。
`--local-config` 表示从本项目 `.env` 读取 `dieya-plm` 对应密钥,密钥和签名不写入输出。
在外部环境可以改为设置 `RISK_CLIENT_SECRET` 或 `RISK_INTEGRATION_CLIENT_SECRET`,不传 `--local-config`。
脚本首先查询原请求。不存在时才提交原参数生成,失败记录允许使用原快照重新提交。
取得报告后,重复 POST 同一业务参数验证幂等,再 GET 查询验证正文及元数据一致。
HTTP 202、429 会遵守 `Retry-After` 等待;完整测试总期限 180 秒,单次连接最多 10 秒、请求最多 130 秒。
POST 网络异常后先 GET 查询,避免直接换编号重复生成。其他错误会保留诊断与快照并以退出码 1 结束。
## 新生成一份报告
```powershell
.\.venv\Scripts\python.exe scripts/test_plm_risk_report.py --local-config --new-request
```
这会生成唯一的 `PLM-TEST-...` 请求编号。其他业务字段(包括截止日期)保持原样,便于与原报告比较。
如需要不同产品、日期或操作人,准备新的 JSON 文件,再通过 `--body-file` 指定。
可指定访问地址和输出目录:
```powershell
.\.venv\Scripts\python.exe scripts/test_plm_risk_report.py --local-config --url http://127.0.0.1:18474/api/v1/integrations/risk-assessments/report --output-dir .runtime-uploads/integration-tests/my-run
```
## 输出文件
默认保存在 `.runtime-uploads/integration-tests/<北京时间及随机后缀>/`,该目录已被 Git 忽略。
| 文件 | 内容 |
|---|---|
| `request.json` | 实际发送的紧凑 UTF-8 请求快照,用于恢复 |
| `report.md` | 接口实际返回的完整 Markdown,不进行重写 |
| `response.json` | 状态、耗时、安全响应头、报告摘要、测试检查项或失败诊断 |
| `summary.md` | 成功时的分数、等级、维度数量及报告链接 |
响应头校验包括请求编号、评估 ID、模型版本、0~100 安全分、合法风险等级;还会核对七个维度行。
HTTP 客户端只解压一次,报告以 UTF-8 校验,解压后的上限为 1MiB。
当前“护发素”不匹配八类品类评分规则,报告会显示无法归类和品类维度默认 50 分。
HTTP 200 表示调用成功,不代表所有维度具备真实数据。新旧报告的评分可比较,AI 建议文本可能不同。

View File

@ -0,0 +1,21 @@
{
"requestId": "PLM-REQ202610090001-002",
"sourceSystem": "dieya-plm",
"operator": {
"externalUserId": "superadmin",
"name": "周子墨"
},
"assessment": {
"subjectId": "REQ202610090001",
"productName": "泊紫汀兰PDRN鱼子酱角蛋白护发素",
"productCategory": "护发素",
"specification": "750ml",
"materialType": "PET瓶身",
"supplier": null,
"factory": null,
"stageNew": "需求发现",
"defectLevel": null,
"description": "PLM需求 REQ202610090001 新品上市前风险评估",
"asOfDate": "2026-10-09"
}
}

View File

@ -0,0 +1,550 @@
# 外部系统风险评估报告接口
## 1. 文档说明
本文档定义外部业务系统调用蝶雅风险预警平台生成新品风险评估 Markdown 报告的接口协议。
> 外部接口代码已实现。启用前需执行数据库迁移、配置系统调用密钥并开通本地钉钉用户;测试和正式地址由部署方提供。本接口与内部 `POST /api/v1/risk-predictions/preview` 分离,不依赖内部用户 JWT 或聊天会话。
### 1.1 接口用途
外部系统提交产品、包装、供应商和开发阶段等风险评估参数,蝶雅平台完成以下处理:
1. 校验调用方系统身份和请求签名。
2. 根据钉钉用户标识,或管理员预先批准的外部账号映射,匹配蝶雅平台用户。
3. 使用蝶雅平台风险数据库、标准库和评分模型完成风险评估。
4. 生成 Markdown 格式的风险评估报告并直接返回。
### 1.2 当前数据边界
外部系统与蝶雅平台使用不同数据库时,蝶雅平台默认只使用自己的历史异常案例、采购订单、供应商主数据和标准规则库。外部系统数据库中的历史数据不会自动参与评分;如需使用,双方另行约定数据同步或 `externalEvidence` 扩展协议。
## 2. 接口信息
| 项目 | 内容 |
|---|---|
| 请求方式 | `POST` |
| 请求路径 | `/api/v1/integrations/risk-assessments/report` |
| 请求类型 | `application/json` |
| 成功响应类型 | `text/markdown; charset=utf-8` |
| 字符编码 | UTF-8 |
| 接口超时时间建议 | 120 秒 |
| 幂等依据 | `sourceSystem + requestId`;`Idempotency-Key` 必须与 `requestId` 一致 |
生产环境完整地址由蝶雅平台提供,例如:
```text
https://risk.example.com/api/v1/integrations/risk-assessments/report
```
## 3. 调用鉴权
接口使用“调用方系统身份 + HMAC-SHA256 签名”鉴权。外部系统不使用蝶雅平台用户 JWT,也不直接传入蝶雅平台本地 `userId`。
### 3.1 请求头
| 请求头 | 必填 | 说明 |
|---|---:|---|
| `Content-Type` | 是 | 固定为 `application/json` |
| `X-Client-Id` | 是 | 蝶雅平台分配的调用方系统编号,例如 `dieya-plm` |
| `X-Timestamp` | 是 | Unix 时间戳,单位为秒 |
| `X-Nonce` | 是 | 8~64 位随机字符串,仅允许字母、数字、下划线、短横线,单次请求唯一 |
| `X-Signature` | 是 | HMAC-SHA256 签名,固定使用标准 Base64 编码(带填充 `=`) |
| `Idempotency-Key` | 是 | 与请求体中的 `requestId` 保持一致 |
### 3.2 签名计算
蝶雅平台为每个 `X-Client-Id` 分配独立密钥 `clientSecret`。POST 调用使用以下原文计算签名:
```text
X-Timestamp + "\n" + X-Nonce + "\n" + 原始请求Body
```
伪代码:
```python
message = f"{timestamp}\n{nonce}\n{raw_body}"
signature = base64.b64encode(
hmac.new(client_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
).decode("ascii")
```
注意事项:
- 签名必须基于发送时的原始 JSON 字节内容计算,不能先格式化后再计算。
- 服务端默认只接受时间误差不超过 5 分钟的请求。
- 已认证请求的 `X-Nonce` 在数据库中保留 10 分钟,跨进程和重启后仍拒绝重复使用;正常 JSON 的字段校验失败也会消耗 Nonce。
- `clientSecret` 只通过双方约定的安全渠道保存,不放在前端代码、日志或请求体中。
- 生产环境建议叠加 IP 白名单;高安全场景可使用 mTLS。
### 3.3 用户身份映射
请求中的用户身份使用钉钉标识:
- `dingtalkUnionId`:首选跨系统关联标识。
- `dingtalkUserId`:钉钉用户 ID,作为辅助校验和追踪字段。
- `employeeNo`、`name`:展示和审计信息,不能单独作为身份依据。
蝶雅平台按以下顺序处理:
1. 按 `dingtalkUnionId` 查找本地用户。
2. 必要时结合 `dingtalkUserId` 校验用户身份。
3. 用户不存在、停用或 UnionId 匹配到多个本地用户时返回 `403`,不自动创建账号。
4. 评分、审计和报告记录使用蝶雅平台本地用户 ID;该 ID 不返回给外部系统作为跨库主键。
用户需要拥有本地 `risk.assess` 操作权限(管理员直接允许)。如传入 `dingtalkUserId`,必须与本地用户一致。报告中的姓名使用本地用户资料,不使用调用方提交的姓名作为认证或显示依据。查询报告时再次校验原操作人的账号状态及权限。
### 3.4 非钉钉操作人
没有钉钉身份的用户可使用 `operator.externalUserId`,前提是风险平台管理员已为当前调用系统批准映射。两种身份模式必须且只能选一种;外部账号模式不传 `dingtalkUnionId`、`dingtalkUserId`,可以省略或传 `null`。不允许按姓名、员工编号自动匹配用户。
例如 PLM 账号 `superadmin`(周子墨)的操作人参数为:
```json
{
"externalUserId": "superadmin",
"name": "周子墨"
}
```
这个对象替换请求中的整个 `operator`,其他字段及系统签名要求不变。PLM 后端必须从当前已登录用户取出该账号标识,不能让前端任意指定别人的 `externalUserId`。平台通过 `X-Client-Id + externalUserId` 查映射并检查本地账号及权限,未批准映射返回 `403`。外部系统的管理员角色不授予风险平台管理员权限。
GET 查询时也会重新校验记录中的外部账号映射;撤销映射或停用本地账号后禁止查询。
如果两个钉钉应用返回的 UnionId 不一致,双方需要先确定统一的钉钉身份源或增加统一身份映射表。
## 4. 请求参数
### 4.1 请求体
```json
{
"requestId": "PLM-D-FT-003-0001",
"sourceSystem": "dieya-plm",
"operator": {
"dingtalkUnionId": "ding_union_xxx",
"dingtalkUserId": "ding_user_xxx",
"employeeNo": "A10001",
"name": "张三"
},
"assessment": {
"subjectId": "SKU-20261007-001",
"productName": "新品洗发水",
"productCategory": "洗发水/洗护类",
"specification": "500ml",
"materialType": "PET瓶/泵头",
"supplier": "某某供应商",
"factory": "某某加工厂",
"stageNew": "产品开发",
"defectLevel": null,
"description": "新品上市前风险评估",
"asOfDate": "2026-10-07"
}
}
```
### 4.2 顶层字段
| 字段 | 类型 | 必填 | 说明 | 校验建议 |
|---|---|---:|---|---|
| `requestId` | string | 是 | 调用方请求唯一编号,用于幂等 | 1~128 个字符,仅允许字母、数字、`_`、`.`、`:`、`-` |
| `sourceSystem` | string | 是 | 来源系统编号 | 必须是已登记的 `X-Client-Id` |
| `operator` | object | 是 | 发起评估的钉钉用户 | 见下表 |
| `assessment` | object | 是 | 风险评估数据 | 见下表 |
### 4.3 `operator` 字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `dingtalkUnionId` | string/null | 条件必填 | 钉钉用户模式必填,与 `externalUserId` 二选一 |
| `externalUserId` | string/null | 条件必填 | 非钉钉用户的调用方稳定账号标识,1~128 字符,必须在当前调用系统下预先批准映射 |
| `dingtalkUserId` | string | 建议 | 钉钉用户 ID |
| `employeeNo` | string | 否 | 员工编号,用于审计展示 |
| `name` | string | 否 | 用户姓名,用于报告展示 |
### 4.4 `assessment` 字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `subjectId` | string | 是 | 产品、SKU 或评估对象编号 |
| `productName` | string | 否 | 产品名称 |
| `productCategory` | string | 是 | 按当前风险评分的八类传值,完整清单及兼容规则见 4.6 |
| `specification` | string | 否 | 规格或容量,例如 `500ml`、`100g` |
| `materialType` | string | 是 | 包装材质或物料组合,例如 `PET瓶/泵头` |
| `supplier` | string | 否 | 供应商名称 |
| `factory` | string | 否 | 加工厂名称 |
| `stageNew` | string | 否 | 开发阶段 |
| `defectLevel` | string/null | 否 | 缺陷等级,可取 `A`、`B`、`C` |
| `description` | string | 否 | 评估背景或补充说明 |
| `asOfDate` | string/null | 否 | 评估数据截止日期,格式 `YYYY-MM-DD`,不早于 1900-01-01、不允许未来日期;省略时使用首次受理的上海日期 |
`stageNew` 建议使用以下枚举值:
```text
需求发现
产品立项
产品开发
量产上市
产品生命管理
```
### 4.5 字段约束
- `productCategory`、`materialType`、`subjectId` 缺失时,接口返回 `422`。
- `description` 仅作为评估背景,不直接作为已确认的检测结果或历史事实。
- 调用方不要提交最终风险分数、风险等级或模型版本;这些字段由蝶雅平台统一计算。
- 当前版本不接收外部数据库连接信息、SQL、文件路径或可执行脚本。
- 可选字段允许省略或传 `null`。`stageNew`、`defectLevel` 非空时必须符合枚举;未知字段(包括调用方提交的分数、角色)返回 `422`。
- 参数长度:`subjectId` 128、`productName` 200、`productCategory` 100、`specification` 100、`materialType` 200、`supplier/factory` 300、`description` 2000;`dingtalkUnionId` 64、`dingtalkUserId` 50、`employeeNo` 64、`name` 50。请求体最大 64KB。
### 4.6 产品品类字典(当前评分口径)
本次 PLM 对接采用现有七维风险评分服务 `normalize_category()` 的八类口径。以下为当前评分分类清单,并非数据库字典接口的返回结果。
PLM 优先将产品品类映射成下表的标准值后提交 `assessment.productCategory`,并保存映射关系。无法确定具体类别时,不应仅凭 PLM 的宽泛业务大类强行映射。
| 标准值(建议提交值) | 当前兼容识别关键词 |
|---|---|
| `洗发水/洗护类` | 洗发、洗护、洗发水、洗发露、洗发乳 |
| `沐浴露类` | 沐浴、沐浴露 |
| `面霜/护肤类` | 面霜、乳液、精华、面膜、护肤 |
| `牙膏/口腔护理类` | 牙膏、口腔 |
| `清洁剂类` | 清洁剂、清洁、洗洁 |
| `脱毛类` | 脱毛 |
| `彩妆/唇部类` | 彩妆、口红、粉底、眼影 |
| `香氛/止汗类` | 香氛、香水、止汗 |
匹配规则及评分边界:
- 当前按上表顺序进行关键词子串匹配,多个类别同时命中时取第一类。
- 空值仍按本接口必填规则返回 `422`。非空值无法识别时,按现有评分逻辑将“产品品类”维度设为默认 50 分,并在报告中注明无法归类;这不代表产品风险已经由历史数据验证。
- 已识别品类的历史异常案例少于 10 条时,该维度按 45 分处理;其余情况按现有异常数量归一化规则计算。
- 当前 `防晒`、`洁面`、`护发素` 等名称本身没有专门的评分匹配规则;包含其他兼容关键词的完整名称仍按现有规则归类,例如“防晒乳液”命中“乳液”。
- `个护清洁` 会命中“清洁”并归为“清洁剂类”,不能将该 PLM 大类整体作为防晒等产品的评分分类。
- 报告应同时展示调用方提交的品类和实际评分归类;无法识别时注明默认分及原因。
历史案例的品类维度统计优先归一化案例的 `product_category`,无法识别时再尝试案例的 `product_name`。本对接口径采用评分规则;看板中的“身体护理类”“洁面/卸妆类”“头发护理类”“包材/物料专项”“待补标品类”不属于当前八类评分字典。
## 5. 成功响应
### 5.1 响应头
```http
HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8
X-Request-Id: PLM-D-FT-003-0001
X-Assessment-Id: 10086
X-Model-Version: database-seven-dimension-v1
X-Risk-Score: 72.50
X-Risk-Level: moderately_low
```
### 5.2 响应体
响应体直接为 Markdown 文本,不再包裹 `data`、`code` 等 JSON 字段:
```markdown
# 新品风险评估报告
## 一、评估信息
| 项目 | 内容 |
|---|---|
| 评估对象 | 新品洗发水 |
| 对象编号 | SKU-20261007-001 |
| 产品品类 | 洗发水/洗护类 |
| 规格 | 500ml |
| 包装材质 | PET瓶/泵头 |
| 供应商 | 某某供应商 |
| 加工厂 | 某某加工厂 |
| 开发阶段 | 产品开发 |
| 评估人 | 张三 |
| 评估时间 | 2026-10-07 |
## 二、综合风险结论
- 综合得分:72.50
- 风险等级:较低风险
- 模型版本:database-seven-dimension-v1
## 三、七维风险评分
| 风险维度 | 得分 | 权重 | 数据状态 | 说明 |
|---|---:|---:|---|---|
| 供应商表现 | 80.00 | 25% | database | 近三年采购订单和异常案例统计 |
| 物料品类 | 75.00 | 20% | database | 包装材质历史异常统计 |
| 品类历史 | 70.00 | 15% | database | 产品品类历史案例统计 |
## 四、相似异常案例
暂无直接匹配案例,建议结合相似品类进行验证。
## 五、标准规则
| 风险点 | 判定规则 | 来源 | 状态 |
|---|---|---|---|
| 密封性 | 按标准要求完成密封性验证 | 标准文件.xlsx | 待检测 |
## 六、风险预防建议
1. 打样阶段完成内容物与包装材料相容性测试。
2. 增加密封性和加速老化测试。
3. 首单加强来料检验和过程巡检。
## 七、数据口径说明
本报告基于蝶雅风险平台本地数据库中的历史案例、采购订单和标准库生成。
部分维度无实际数据时,按系统配置的中性值计算。
```
报告中的分数是安全得分,分数越高代表风险越低。风险等级阈值以蝶雅平台当前模型配置为准,调用方不要自行重新计算等级。
`X-Risk-Score` 为 0~100 的安全分,保留两位小数。`X-Risk-Level` 为 `low`(低风险)、`moderately_low`(较低风险)、`medium`(中等风险)、`high`(高风险)、`very_high`(极高风险)。`X-Assessment-Id` 按字符串存储。GET 和幂等重试返回原报告及相同结论响应头。
## 6. 幂等规则
`requestId` 是调用方生成的业务请求唯一编号,同一个 `sourceSystem + requestId` 只能对应一次评估请求。
### 首次请求
返回 `200` 和 Markdown 报告。
### 相同请求重复提交
如果请求体和原请求一致,服务端返回同一份已生成报告,仍返回 `200`,并在响应头中带回原始 `X-Assessment-Id`。
### 同一编号提交不同内容
如果 `requestId` 已经使用,但请求内容摘要不同,返回 `409`,避免覆盖历史报告。
### 处理中、失败及重试
- 幂等比较基于校验后的 JSON 对象摘要,忽略 JSON 键顺序和排版,省略的可选字段与 `null` 等价;变更任意有效字段视为不同请求。
- 重试保留原请求体(包括操作人和 `asOfDate`),重新生成时间戳、Nonce 和签名。主动重新评估必须使用新 `requestId`。
- 同一请求仍在生成时,POST 或 GET 返回 `202` JSON 和 `Retry-After: 5`,不会重复计算。
- 每次生成最多 110 秒(可配置为 10~110 秒),超时或失败记录为 failed,返回 `503`;相同请求内容可以重新 POST 重试。生成权租约为 150 秒,进程异常退出后租约到期可重新 POST 接管。
- 调用方网络超时并不代表服务端已经停止。应先 GET 查询,再决定是否使用原请求重试。
- 省略 `asOfDate` 时首次受理日期单独持久化,跨天重试仍沿用首次日期。
处理中响应示例:
```json
{"code":202,"message":"报告正在生成,请稍后查询","data":{"requestId":"PLM-D-FT-003-0001"}}
```
### 报告查询
```http
GET /api/v1/integrations/risk-assessments/report?requestId=PLM-D-FT-003-0001
```
查询参数必须且只能包含一个 `requestId`;GET 不携带 Body,也不要求 `Idempotency-Key`。仅能查询与 `X-Client-Id` 相同来源的记录。成功返回 `200` Markdown,处理中返回 `202`,失败返回 `503`,不存在返回 `404`。
GET 签名原文为:
```text
"GET" + "\n" + 请求路径 + "\n" + 原始查询字符串 + "\n" + X-Timestamp + "\n" + X-Nonce + "\n"
```
请求路径包含 `/api/v1`,不含域名、查询字符串;原始查询字符串不含 `?`,包括实际发送的 URL 编码,签名后不得重新编码。网关不能重写对外接口路径。以 UTF-8 字节计算 HMAC-SHA256,再以标准 Base64 编码。报告持久化保存,当前未启用自动清理策略。
## 7. 错误响应
错误响应统一使用 JSON:
```json
{
"code": 422,
"message": "产品品类不能为空",
"data": null
}
```
| HTTP 状态码 | `code` | 场景 | 调用方处理建议 |
|---:|---:|---|---|
| `400` | `400` | 请求格式错误或签名原文不合法 | 修正请求格式后重试 |
| `401` | `401` | 缺少签名、签名错误、时间戳过期 | 检查系统密钥和签名算法 |
| `403` | `403` | 调用方未开通接口或用户无风险评估权限 | 联系平台管理员开通权限 |
| `404` | `404` | 当前调用系统未找到该请求 | 核对 requestId 和调用系统 |
| `409` | `409` | `requestId` 重复但内容不一致 | 更换请求编号或查询原请求 |
| `422` | `422` | 参数缺失或字段值不符合约束 | 修正参数后重试 |
| `429` | `429` | 超过调用频率限制 | 按 `Retry-After` 延迟重试 |
| `500` | `500` | 平台内部处理异常 | 使用同一 `requestId` 重试并联系平台方 |
| `503` | `503` | 风险数据库、模型或依赖服务暂不可用 | 稍后重试,不要生成新的业务编号 |
## 8. 调用示例
### 8.1 cURL
```bash
curl -X POST 'https://risk.example.com/api/v1/integrations/risk-assessments/report' \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: dieya-plm' \
-H 'X-Timestamp: 1791331200' \
-H 'X-Nonce: 8f3d4c1a9e' \
-H 'X-Signature: Base64HmacSha256Value' \
-H 'Idempotency-Key: PLM-D-FT-003-0001' \
--data-raw '{
"requestId": "PLM-D-FT-003-0001",
"sourceSystem": "dieya-plm",
"operator": {
"dingtalkUnionId": "ding_union_xxx",
"dingtalkUserId": "ding_user_xxx",
"employeeNo": "A10001",
"name": "张三"
},
"assessment": {
"subjectId": "SKU-20261007-001",
"productName": "新品洗发水",
"productCategory": "洗发水/洗护类",
"specification": "500ml",
"materialType": "PET瓶/泵头",
"supplier": "某某供应商",
"factory": "某某加工厂",
"stageNew": "产品开发",
"description": "新品上市前风险评估",
"asOfDate": "2026-10-07"
}
}' \
--output risk-report.md
```
### 8.2 Python 签名示例
```python
import base64
import hashlib
import hmac
import json
import secrets
import time
body = {
"requestId": "PLM-D-FT-003-0001",
"sourceSystem": "dieya-plm",
"operator": {"dingtalkUnionId": "ding_union_xxx"},
"assessment": {
"subjectId": "SKU-20261007-001",
"productCategory": "洗发水/洗护类",
"materialType": "PET瓶/泵头",
},
}
raw_body = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
nonce = secrets.token_urlsafe(16)
message = timestamp.encode() + b"\n" + nonce.encode() + b"\n" + raw_body
signature = base64.b64encode(
hmac.new(client_secret.encode(), message, hashlib.sha256).digest()
).decode()
```
调用方必须使用计算签名时的同一份 `raw_body` 发送请求。
## 9. 评分和报告处理说明
平台内部处理逻辑建议统一复用现有七维风险评分服务:
- 供应商表现
- 物料品类
- 品类历史
- 加工厂质量
- 标准符合性
- 节点阶段
- 缺陷严重度
平台会在报告中标注数据状态。没有真实数据库数据的维度不得伪造历史结论,按模型配置的中性值处理并在报告中说明。
外部接口使用结构化参数直接评分,不再通过聊天语义提取参数。沿用现有评分区间起点(截止年份往前 3 年的 1 月 1 日),区间终点为 `asOfDate`,不包含截止日期之后的案例及采购订单。相似案例使用现有检索服务的文本和业务字段排序,输入同一统计区间内的案例;本接口不调用向量嵌入服务。
包装组合沿用现有单标签评分口径,例如 `PET 瓶身 + PP 泵头` 命中“泵头”,不代表分别评估了所有组件。报告展示原文及评分标签。缺陷维度来自历史 A/B/C 记录,不预测新品实际缺陷等级。
AI 只用于生成预防建议,等待最多 20 秒。AI 不可用时仍返回数据库评分及固定验证建议,并在报告中标注;数据库评分失败则返回 `503`,不生成模拟分数。
报告建议包含:
1. 评估信息。
2. 综合风险得分和风险等级。
3. 七维评分明细。
4. 品类、供应商和物料历史统计。
5. 相似异常案例。
6. 标准规则匹配结果。
7. 风险预防建议。
8. 数据口径和模型版本。
## 10. 联调前需要双方确认的事项
1. 正式接口域名、环境地址和接口版本。
2. `sourceSystem`、`X-Client-Id` 和密钥分配方式。
3. 钉钉 `unionId` 是否可以作为双方统一用户标识;如果不能,需要提供统一身份映射方案。
4. 操作人提前开通、UnionId 唯一映射及 `risk.assess` 权限;本接口已采用不存在返回 `403` 的策略。
5. 是否启用 IP 白名单、mTLS 和调用频率限制。
6. `stageNew`、`defectLevel` 枚举,以及 PLM 到第 4.6 节八类评分品类的映射关系;确认无法归类时的默认分提示。
7. 外部系统历史数据是否需要同步到蝶雅平台。
8. Markdown 报告的保存、下载和展示方式。
9. 超时、重试和 `requestId` 查询机制。
## 11. 服务端启用配置
先执行迁移:
```powershell
python -m alembic upgrade head
```
新增表为 `integration_nonces` 和 `integration_assessments`,分别保存已认证随机串及请求快照、处理状态、评分结果和 Markdown 报告;成功评估同时写入操作审计。现有用户表不自动添加外部用户。报告记录通过本地用户编号逻辑关联,不对已有用户表创建外键,迁移账号不需要该表的 `REFERENCES` 权限;用户状态和权限仍由接口校验。
在服务端环境配置中设置:
```dotenv
RISK_INTEGRATION_CLIENTS={"dieya-plm":"replace-with-at-least-32-random-characters"}
RISK_INTEGRATION_REQUESTS_PER_MINUTE=60
RISK_INTEGRATION_REPORT_TIMEOUT_SECONDS=110
```
上面的密钥仅为占位,须替换为至少 32 个字符的随机密钥;测试、正式各使用一套。调用系统编号仅允许字母、数字、下划线和短横线,最长 64 位。默认 `{}` 不允许任何系统调用。配置后重启服务。
也可以在后端项目根目录使用启用工具生成密钥,工具会保留 `.env` 中的其他设置及已有系统密钥,不打印密钥、不自动轮换已有密钥:
```powershell
.\.venv\Scripts\python.exe scripts/setup_external_risk.py configure --client-id dieya-plm
```
让操作人在钉钉工作台中登录风险平台,正常免登会同步用户资料和 UnionId。然后使用 PLM 保存的真实 UnionId 检查映射与权限:
```powershell
.\.venv\Scripts\python.exe scripts/setup_external_risk.py check-user --union-id "PLM操作人的真实UnionId" --dingtalk-user-id "同一人的真实UserId"
```
工具只检查、不修改账号或权限。不存在时先通过钉钉免登开通;已停用或无权限时由账号管理员确认处理;映射不唯一或身份不一致时核对两边钉钉身份源,不能按姓名猜测绑定。当前启用的 admin、product_manager、reviewer、user 角色均有风险评估操作权限,以本地 `role_permissions` 表实际记录为准。
限流按调用系统统计已通过签名的请求,POST、GET、幂等重试共用滚动 60 秒窗口;达到阈值返回 `429` 和 `Retry-After: 60`。IP 白名单和 mTLS 可在网关配置,当前应用层不提供这两项配置。
## 12. 联调调用工具
非钉钉用户需由风险平台管理员在本地显式开通,例如:
```powershell
.\.venv\Scripts\python.exe scripts/setup_external_risk.py allow-user --client-id dieya-plm --external-user-id superadmin --employee-no plm_superadmin --name "周子墨"
```
工具创建无密码、无钉钉绑定的普通本地账号,并写入 `.env`:
```dotenv
RISK_INTEGRATION_OPERATOR_MAPPINGS='{"dieya-plm":{"superadmin":"plm_superadmin"}}'
```
映射目标是本地 `emp_no`。默认 `{}` 禁止所有非钉钉账号;已有映射不会自动指向其他账号,已有账号不会自动更改角色、姓名或启用状态。配置后重启后端,无需数据库迁移。
项目提供 `scripts/call_external_risk_report.py`,自动生成 POST/GET 签名并保存 Markdown。调用方将本系统分配的密钥写入环境变量 `RISK_INTEGRATION_CLIENT_SECRET`(客户端单个密钥,与服务端 JSON 密钥表是不同配置)。`assessment.json` 使用本文第 4.1 节请求结构,填入已开通的真实 UnionId。
```powershell
python scripts/call_external_risk_report.py --url https://risk.example.com/api/v1/integrations/risk-assessments/report --client-id dieya-plm --body-file assessment.json --output risk-report.md
```
查询原请求:
```powershell
python scripts/call_external_risk_report.py --url https://risk.example.com/api/v1/integrations/risk-assessments/report --client-id dieya-plm --request-id PLM-D-FT-003-0001 --output risk-report.md
```
只有 `200` 会保存报告;`202`/错误响应会展示 JSON 和重试等待时间,不会覆盖已有 Markdown。

View File

@ -0,0 +1,49 @@
"""External reports, idempotency leases and durable HMAC replay protection."""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20261008_0022"
down_revision = "20260911_0021"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"integration_nonces",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("client_id", sa.String(64), nullable=False),
sa.Column("nonce", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("client_id", "nonce", name="uq_integration_nonce"),
)
op.create_index("ix_integration_nonce_expires", "integration_nonces", ["expires_at"])
op.create_index("ix_integration_nonce_client_created", "integration_nonces", ["client_id", "created_at"])
op.create_table(
"integration_assessments",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("client_id", sa.String(64), nullable=False),
sa.Column("request_id", sa.String(128), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("request_hash", sa.String(64), nullable=False),
sa.Column("request_data", postgresql.JSONB(), nullable=False),
sa.Column("as_of_date", sa.Date(), nullable=False),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("owner_token", sa.String(32), nullable=False),
sa.Column("lease_until", sa.DateTime(timezone=True), nullable=False),
sa.Column("result_data", postgresql.JSONB()),
sa.Column("report_markdown", sa.Text()),
sa.Column("error_message", sa.String(200)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("client_id", "request_id", name="uq_integration_assessment_request"),
)
op.create_index("ix_integration_assessment_user", "integration_assessments", ["user_id"])
def downgrade() -> None:
op.drop_table("integration_assessments")
op.drop_table("integration_nonces")

View File

@ -0,0 +1,68 @@
"""POST an assessment or GET a persisted report using the documented HMAC protocol."""
import argparse
import base64
import hashlib
import hmac
import os
import secrets
import time
from pathlib import Path
from urllib.parse import urlencode, urlsplit
import httpx
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", required=True, help="Full /api/v1/integrations/risk-assessments/report URL")
parser.add_argument("--client-id", default="dieya-plm")
inputs = parser.add_mutually_exclusive_group(required=True)
inputs.add_argument("--body-file", type=Path, help="UTF-8 JSON assessment to POST")
inputs.add_argument("--request-id", help="Existing requestId to GET")
parser.add_argument("--output", type=Path, default=Path("risk-report.md"))
args = parser.parse_args()
secret = os.environ.get("RISK_INTEGRATION_CLIENT_SECRET", "")
if len(secret) < 32:
parser.error("Set RISK_INTEGRATION_CLIENT_SECRET to the system's secret (at least 32 characters)")
url = urlsplit(args.url)
if url.scheme not in {"https", "http"} or url.query or url.fragment:
parser.error("--url must be an HTTP(S) endpoint without query or fragment")
timestamp, nonce = str(int(time.time())), secrets.token_urlsafe(16)
message = timestamp.encode() + b"\n" + nonce.encode() + b"\n"
headers = {"X-Client-Id": args.client_id, "X-Timestamp": timestamp, "X-Nonce": nonce}
if args.body_file:
import json
body = args.body_file.read_bytes()
data = json.loads(body)
headers.update({"Content-Type": "application/json", "Idempotency-Key": data["requestId"]})
message += body
method, target = "POST", args.url
else:
body = b""
query = urlencode({"requestId": args.request_id})
message = b"GET\n" + url.path.encode() + b"\n" + query.encode() + b"\n" + message
method, target = "GET", args.url + "?" + query
headers["X-Signature"] = base64.b64encode(hmac.new(secret.encode(), message, hashlib.sha256).digest()).decode()
try:
response = httpx.request(method, target, content=body, headers=headers, timeout=120)
except httpx.HTTPError as exc:
print(f"Request did not complete ({exc.__class__.__name__}); query the same requestId before retrying.")
return 1
if response.status_code != 200:
print(f"HTTP {response.status_code}: {response.text}")
if response.headers.get("Retry-After"):
print(f"Retry-After: {response.headers['Retry-After']} seconds")
return 1
args.output.write_text(response.text, encoding="utf-8")
print(f"Saved {args.output.resolve()}")
print(
f"Assessment: {response.headers.get('X-Assessment-Id')}, "
f"score: {response.headers.get('X-Risk-Score')}, level: {response.headers.get('X-Risk-Level')}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,177 @@
"""Configure a backend integration secret or verify an existing DingTalk operator."""
import argparse
import asyncio
import json
import re
import secrets
from pathlib import Path
from app.core.exceptions import AppException
from app.database.session import SessionFactory, engine
from app.models.entities import UserAccount
from app.repositories.users import UserRepository
from app.schemas.integration_risk import IntegrationOperator
from app.services.integration_auth import authorize_operator
from dotenv import dotenv_values
from sqlalchemy import select
def configure_client(env_file: Path, client_id: str) -> bool:
"""Keep existing secrets and unrelated settings; never print secret material."""
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", client_id):
raise ValueError("client-id 仅允许字母、数字、下划线和短横线,最长 64 位")
text = env_file.read_text(encoding="utf-8-sig") if env_file.exists() else ""
raw = dotenv_values(env_file).get("RISK_INTEGRATION_CLIENTS") if env_file.exists() else None
clients = json.loads(raw or "{}")
if not isinstance(clients, dict) or any(
not isinstance(key, str)
or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key)
or not isinstance(value, str)
or len(value) < 32
for key, value in clients.items()
):
raise ValueError("已有 RISK_INTEGRATION_CLIENTS 配置无效,请检查 JSON 和密钥长度")
if client_id in clients:
return False
clients[client_id] = secrets.token_urlsafe(32)
# Prefer readable single-quoted JSON; double-escape if an existing secret has an apostrophe.
encoded = json.dumps(clients, ensure_ascii=True, separators=(",", ":"))
value = "'" + encoded + "'" if "'" not in encoded else json.dumps(encoded, ensure_ascii=True)
line = f"RISK_INTEGRATION_CLIENTS={value}"
lines = text.splitlines()
indexes = [
i for i, current in enumerate(lines) if re.match(r"^\s*(?:export\s+)?RISK_INTEGRATION_CLIENTS\s*=", current)
]
if indexes:
lines[indexes[0]] = line
lines = [current for i, current in enumerate(lines) if i not in indexes[1:]]
else:
lines.extend(["", "# 外部系统调用密钥(仅保存在双方后端)", line])
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
return True
async def check_operator(union_id: str, user_id: str | None) -> None:
try:
async with SessionFactory() as session:
user = await authorize_operator(
session,
IntegrationOperator(
dingtalk_union_id=union_id,
dingtalk_user_id=user_id,
),
)
print(f"检查通过:本地用户 {user.id},姓名 {user.name},角色 {user.role},账号启用,有风险评估操作权限。")
finally:
await engine.dispose()
def read_operator_mappings(env_file: Path) -> dict:
raw = dotenv_values(env_file).get("RISK_INTEGRATION_OPERATOR_MAPPINGS") if env_file.exists() else None
mappings = json.loads(raw or "{}")
if not isinstance(mappings, dict) or any(
not isinstance(client, str)
or not isinstance(accounts, dict)
or any(
not isinstance(external, str) or not external or not isinstance(local, str) or not local or len(local) > 20
for external, local in accounts.items()
)
for client, accounts in mappings.items()
):
raise ValueError("外部账号映射配置无效")
return mappings
def write_operator_mapping(env_file: Path, client_id: str, external_id: str, employee_no: str) -> None:
mappings = read_operator_mappings(env_file)
accounts = mappings.setdefault(client_id, {})
if external_id in accounts and accounts[external_id] != employee_no:
raise ValueError("外部账号已映射到其他本地账号,不能自动覆盖")
accounts[external_id] = employee_no
encoded = json.dumps(mappings, ensure_ascii=True, separators=(",", ":"))
value = "'" + encoded + "'" if "'" not in encoded else json.dumps(encoded)
key = "RISK_INTEGRATION_OPERATOR_MAPPINGS"
lines = env_file.read_text(encoding="utf-8-sig").splitlines() if env_file.exists() else []
indexes = [i for i, line in enumerate(lines) if re.match(r"^\s*(?:export\s+)?" + key + r"\s*=", line)]
if indexes:
lines[indexes[0]] = f"{key}={value}"
lines = [line for i, line in enumerate(lines) if i not in indexes[1:]]
else:
lines.extend(["", "# 已批准的外部操作人映射", f"{key}={value}"])
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
async def allow_external_user(env_file: Path, client_id: str, external_id: str, employee_no: str, name: str) -> None:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", client_id):
raise ValueError("调用系统格式错误")
if not external_id.strip() or len(external_id) > 128 or not name.strip() or len(name) > 50:
raise ValueError("账号标识或姓名格式错误")
if not re.fullmatch(r"[A-Za-z0-9_-]{1,20}", employee_no):
raise ValueError("本地账号编号格式错误")
mappings = read_operator_mappings(env_file)
existing = mappings.get(client_id, {}).get(external_id)
if existing and existing != employee_no:
raise ValueError("已有映射不能自动覆盖")
clients = json.loads(dotenv_values(env_file).get("RISK_INTEGRATION_CLIENTS") or "{}")
if client_id not in clients:
raise ValueError("请先配置调用系统密钥")
try:
async with SessionFactory() as session:
user = await session.scalar(select(UserAccount).where(UserAccount.emp_no == employee_no))
if user is None:
permissions = await UserRepository(session).permissions_for_role("user", operate=True)
if not any(item.func_key == "risk.assess" for item in permissions):
raise ValueError("普通角色无评估权限,请由管理员配置")
user = UserAccount(emp_no=employee_no, name=name, role="user", status="启用")
session.add(user)
await session.flush()
elif user.name != name or user.status != "启用":
raise ValueError("已有本地账号姓名不一致或已停用,不能自动修改")
user_id = user.id
await session.commit()
write_operator_mapping(env_file, client_id, external_id, employee_no)
print(f"已配置外部用户:{name}({external_id}),调用系统 {client_id},本地用户编号 {user_id}。")
print("保持本地账号权限;新建账号为普通用户,无密码及钉钉绑定。请重启后端。")
finally:
await engine.dispose()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
actions = parser.add_subparsers(dest="action", required=True)
configure = actions.add_parser("configure", help="Generate a missing client secret without printing it")
configure.add_argument("--client-id", default="dieya-plm")
configure.add_argument("--env-file", type=Path, default=Path(".env"))
check = actions.add_parser("check-user", help="Check UnionId, optional UserId and local permission")
check.add_argument("--union-id", required=True)
check.add_argument("--dingtalk-user-id")
allow = actions.add_parser("allow-user", help="Explicitly register a non-DingTalk operator for one client")
allow.add_argument("--client-id", default="dieya-plm")
allow.add_argument("--external-user-id", required=True)
allow.add_argument("--employee-no", required=True)
allow.add_argument("--name", required=True)
allow.add_argument("--env-file", type=Path, default=Path(".env"))
args = parser.parse_args()
try:
if args.action == "configure":
changed = configure_client(args.env_file, args.client_id)
print(f"{args.client_id}:" + ("密钥已生成并写入配置。" if changed else "已配置,保留现有密钥。"))
print("密钥不会打印。请重启后端,并通过安全渠道向调用方提供该 clientId 对应的密钥。")
elif args.action == "allow-user":
asyncio.run(
allow_external_user(args.env_file, args.client_id, args.external_user_id, args.employee_no, args.name)
)
else:
asyncio.run(check_operator(args.union_id, args.dingtalk_user_id))
except AppException as exc:
print(f"检查不通过({exc.status_code}):{exc.message}")
return 1
except (ValueError, OSError):
print("配置或参数无效,请检查文件、JSON、标识格式及密钥长度;未显示敏感内容。")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,292 @@
"""Exercise the PLM POST/GET protocol and save the real report without logging credentials."""
import argparse
import base64
import hashlib
import hmac
import json
import math
import os
import re
import secrets
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlencode, urlsplit
import httpx
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PAYLOAD = ROOT / "docs" / "examples" / "plm-risk-request-20261009.json"
DEFAULT_URL = "http://192.168.9.28:18474/api/v1/integrations/risk-assessments/report"
LEVELS = {
"low": "低风险",
"moderately_low": "较低风险",
"medium": "中等风险",
"high": "高风险",
"very_high": "极高风险",
}
SAFE_HEADERS = (
"content-type",
"content-encoding",
"x-request-id",
"x-assessment-id",
"x-model-version",
"x-risk-score",
"x-risk-level",
"retry-after",
)
def signed_headers(method: str, url: str, body: bytes, secret: str, client_id: str, request_id: str) -> dict:
timestamp, nonce = str(int(time.time())), secrets.token_urlsafe(16)
message = timestamp.encode() + b"\n" + nonce.encode() + b"\n"
headers = {"X-Client-Id": client_id, "X-Timestamp": timestamp, "X-Nonce": nonce}
if method == "GET":
target = urlsplit(url)
message = b"GET\n" + target.path.encode() + b"\n" + target.query.encode() + b"\n" + message
else:
message += body
headers.update({"Content-Type": "application/json", "Idempotency-Key": request_id})
headers["X-Signature"] = base64.b64encode(hmac.new(secret.encode(), message, hashlib.sha256).digest()).decode()
return headers
def report_metadata(response: httpx.Response, request_id: str) -> dict:
if response.status_code != 200:
raise ValueError(f"Expected HTTP 200, received {response.status_code}")
if response.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "text/markdown":
raise ValueError("Successful response must be text/markdown")
if response.headers.get("x-request-id") != request_id:
raise ValueError("X-Request-Id does not match this request")
score = float(response.headers.get("x-risk-score", "nan"))
if not math.isfinite(score) or not 0 <= score <= 100:
raise ValueError("Invalid X-Risk-Score")
level = response.headers.get("x-risk-level")
if level not in LEVELS:
raise ValueError("Invalid X-Risk-Level")
assessment_id, model = response.headers.get("x-assessment-id"), response.headers.get("x-model-version")
if not assessment_id or not model:
raise ValueError("Missing assessment ID or model version")
# httpx already decompresses gzip/deflate. Decode the resulting bytes exactly once.
if len(response.content) > 1024 * 1024:
raise ValueError("Decompressed report exceeds 1MiB")
markdown = response.content.decode("utf-8")
if not markdown.strip():
raise ValueError("Empty report")
dimensions = sum(
bool(re.match(r"^\|\s*(供应商表现|物料品类|产品品类|加工厂质量|标准符合性|节点阶段|缺陷等级)\s*\|", line))
for line in markdown.splitlines()
)
if dimensions != 7:
raise ValueError(f"Expected 7 dimension rows, received {dimensions}")
return {
"requestId": request_id,
"assessmentId": assessment_id,
"score": score,
"level": level,
"levelLabel": LEVELS[level],
"modelVersion": model,
"dimensionCount": dimensions,
"reportSha256": hashlib.sha256(response.content).hexdigest(),
}
class PLMReportTest:
def __init__(
self, client: httpx.Client, url: str, secret: str, payload: dict, output: Path, total_timeout: float = 180
):
self.client, self.url, self.secret, self.payload, self.output = client, url, secret, payload, output
self.request_id = payload["requestId"]
self.body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self.query_url = url + "?" + urlencode({"requestId": self.request_id})
self.events = []
self.deadline = time.monotonic() + total_timeout
def send(self, method: str) -> httpx.Response:
remaining = self.deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Overall test deadline exceeded; use the saved snapshot to resume")
url = self.query_url if method == "GET" else self.url
body = b"" if method == "GET" else self.body
headers = signed_headers(method, url, body, self.secret, self.payload["sourceSystem"], self.request_id)
started = time.monotonic()
try:
response = self.client.request(
method,
url,
content=body,
headers=headers,
timeout=httpx.Timeout(min(130, remaining), connect=min(10, remaining)),
)
except httpx.HTTPError as exc:
self.events.append(
{
"method": method,
"transportError": exc.__class__.__name__,
"seconds": round(time.monotonic() - started, 2),
}
)
raise
event = {
"method": method,
"status": response.status_code,
"seconds": round(time.monotonic() - started, 2),
"headers": {key: response.headers[key] for key in SAFE_HEADERS if key in response.headers},
}
if response.status_code != 200:
try:
event["error"] = response.json()
except ValueError:
event["error"] = {"message": "Non-JSON error response"}
self.events.append(event)
print(f"{method} HTTP {response.status_code} ({event['seconds']}s)", flush=True)
return response
def wait(self, response: httpx.Response):
try:
delay = max(1, min(60, int(response.headers.get("retry-after", "5"))))
except ValueError:
delay = 5
if time.monotonic() + delay >= self.deadline:
raise TimeoutError("Retry-After exceeds remaining test deadline")
# Short waits keep progress visible even if rate limiting requests a long delay.
print(f"Wait {delay}s before retry", flush=True)
while delay:
step = min(5, delay)
time.sleep(step)
delay -= step
def resolve(self, response: httpx.Response) -> httpx.Response:
for _ in range(40):
if response.status_code == 200:
return response
if response.status_code not in {202, 429}:
raise ValueError(f"HTTP {response.status_code}; inspect response.json for platform diagnostics")
if response.status_code == 202:
data = response.json()
if data.get("code") != 202 or (data.get("data") or {}).get("requestId") != self.request_id:
raise ValueError("Processing response does not match this request")
self.wait(response)
response = self.send("GET")
raise TimeoutError("Polling limit reached; use the saved snapshot to resume")
def run(self) -> dict:
self.output.mkdir(parents=True, exist_ok=True)
(self.output / "request.json").write_bytes(self.body)
checks = []
first = self.send("GET")
while first.status_code == 429:
self.wait(first)
first = self.send("GET")
if first.status_code in {404, 503}:
try:
first = self.send("POST")
except httpx.HTTPError:
# A timed-out POST may already have persisted the request.
first = self.send("GET")
first = self.resolve(first)
metadata = report_metadata(first, self.request_id)
(self.output / "report.md").write_bytes(first.content)
checks.append("报告为 UTF-8 Markdown,编号、分数、等级及七个维度校验通过")
repeated = self.send("POST")
if repeated.status_code == 429:
self.wait(repeated)
repeated = self.send("POST")
repeated = self.resolve(repeated)
if report_metadata(repeated, self.request_id) != metadata or repeated.content != first.content:
raise ValueError("Repeated POST differs from the original report")
checks.append("同编号同内容 POST 返回原报告,幂等校验通过")
queried = self.resolve(self.send("GET"))
if report_metadata(queried, self.request_id) != metadata or queried.content != first.content:
raise ValueError("GET report differs from the original report")
checks.append("GET 报告正文、评估 ID、分数、等级及模型版本一致")
return {
"success": True,
"endpoint": self.url,
**metadata,
"checks": checks,
"requestSha256": hashlib.sha256(self.body).hexdigest(),
"events": self.events,
}
def load_secret(client_id: str, use_local_config: bool) -> str:
secret = os.environ.get("RISK_CLIENT_SECRET") or os.environ.get("RISK_INTEGRATION_CLIENT_SECRET")
if not secret and use_local_config:
# Explicit opt-in for running this tool in the risk backend workspace.
from dotenv import dotenv_values
raw = os.environ.get("RISK_INTEGRATION_CLIENTS") or dotenv_values(ROOT / ".env").get("RISK_INTEGRATION_CLIENTS")
secret = json.loads(raw or "{}").get(client_id)
if not isinstance(secret, str) or len(secret) < 32:
raise ValueError("Set RISK_CLIENT_SECRET or use --local-config to read this backend's .env")
return secret
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=DEFAULT_URL)
parser.add_argument("--body-file", type=Path, default=DEFAULT_PAYLOAD)
parser.add_argument("--output-dir", type=Path)
parser.add_argument("--local-config", action="store_true", help="Read secret from backend .env without printing it")
parser.add_argument(
"--new-request", action="store_true", help="Use a unique requestId to generate a new assessment"
)
args = parser.parse_args()
stamp = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d-%H%M%S")
output = args.output_dir or ROOT / ".runtime-uploads" / "integration-tests" / (stamp + "-" + secrets.token_hex(3))
output.mkdir(parents=True, exist_ok=True)
runner = None
try:
target = urlsplit(args.url)
if target.scheme not in {"http", "https"} or target.query or target.fragment or target.username:
raise ValueError("Endpoint must be HTTP(S), without credentials, query or fragment")
payload = json.loads(args.body_file.read_text(encoding="utf-8-sig"))
if args.new_request:
payload["requestId"] = f"PLM-TEST-{payload['assessment']['subjectId']}-{stamp}-{secrets.token_hex(4)}"
secret = load_secret(payload["sourceSystem"], args.local_config)
with httpx.Client(follow_redirects=False, trust_env=False) as client:
runner = PLMReportTest(client, args.url, secret, payload, output)
result = runner.run()
except (ValueError, KeyError, OSError, TimeoutError, httpx.HTTPError) as exc:
result = {
"success": False,
"errorType": exc.__class__.__name__,
"message": str(exc)
if not isinstance(exc, (httpx.HTTPError, OSError))
else "Transport or file operation failed",
"events": runner.events if runner else [],
}
(output / "response.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
if result["success"]:
summary = [
"# PLM 风险接口测试结果",
"",
f"- 请求编号:{result['requestId']}",
f"- 平台评估 ID:{result['assessmentId']}",
f"- 综合安全得分:{result['score']:.2f}",
f"- 风险等级:{result['levelLabel']}",
f"- 模型版本:{result['modelVersion']}",
f"- 维度数量:{result['dimensionCount']}",
"",
"报告:[report.md](report.md)",
"",
]
summary.extend(f"- {check}" for check in result["checks"])
summary.extend(
[
"",
"本次测试沿用接口现有评分规则;HTTP 200 不代表全部维度拥有真实数据。",
"当品类为护发素时,当前规则可能无法归类,报告会注明品类维度默认分。",
"",
]
)
(output / "summary.md").write_text("\n".join(summary), encoding="utf-8")
print(f"Score: {result['score']:.2f}; level: {result['level']}; assessment: {result['assessmentId']}")
print(f"Outputs: {output.resolve()}")
return 0 if result["success"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,43 @@
"""Secret configuration must preserve unrelated settings and existing client credentials."""
import json
from dotenv import dotenv_values
from scripts.setup_external_risk import configure_client, read_operator_mappings, write_operator_mapping
def test_configure_keeps_settings_and_does_not_rotate_existing_secret(tmp_path):
env_file = tmp_path / ".env"
env_file.write_text("RISK_DATABASE_URL=keep-this-value\n# 保留注释\n", encoding="utf-8")
assert configure_client(env_file, "dieya-plm")
values = dotenv_values(env_file)
clients = json.loads(values["RISK_INTEGRATION_CLIENTS"])
assert len(clients["dieya-plm"]) >= 32
assert values["RISK_DATABASE_URL"] == "keep-this-value"
previous = env_file.read_bytes()
assert not configure_client(env_file, "dieya-plm")
assert env_file.read_bytes() == previous
def test_external_mapping_preserves_secret_and_other_mappings(tmp_path):
env_file = tmp_path / ".env"
assert configure_client(env_file, "dieya-plm")
secret = dotenv_values(env_file)["RISK_INTEGRATION_CLIENTS"]
write_operator_mapping(env_file, "other-system", "user-1", "employee-1")
write_operator_mapping(env_file, "dieya-plm", "superadmin", "plm_superadmin")
assert read_operator_mappings(env_file) == {
"other-system": {"user-1": "employee-1"},
"dieya-plm": {"superadmin": "plm_superadmin"},
}
assert dotenv_values(env_file)["RISK_INTEGRATION_CLIENTS"] == secret
def test_configure_preserves_other_secret_with_quotes_and_backslashes(tmp_path):
env_file = tmp_path / ".env"
existing = "a'b\"c\\d" + "-" * 40
encoded = json.dumps(json.dumps({"other-system": existing}))
env_file.write_text(f"RISK_INTEGRATION_CLIENTS={encoded}\n", encoding="utf-8")
assert configure_client(env_file, "dieya-plm")
clients = json.loads(dotenv_values(env_file)["RISK_INTEGRATION_CLIENTS"])
assert clients["other-system"] == existing
assert len(clients["dieya-plm"]) >= 32

View File

@ -0,0 +1,494 @@
"""HTTP integration checks against an isolated relational test database.
SQLite runs the real persistence/ON CONFLICT paths. Its advisory-lock stub does
not simulate PostgreSQL worker locking; deployment still needs PostgreSQL migration.
"""
import asyncio
import base64
import hashlib
import hmac
import json
import time
import uuid
from datetime import UTC, date, datetime, timedelta
from types import SimpleNamespace
import httpx
import pytest
from app.api.v1 import integration_risk
from app.database.base import Base
from app.database.session import get_db_session
from app.main import create_app
from app.models.entities import (
AnomalyCase,
IntegrationAssessment,
IntegrationNonce,
RolePermission,
UserAccount,
)
from app.repositories.integration_risk import IntegrationRiskRepository
from app.services import integration_auth
from app.services.llm_client import LLMClient
from pydantic import SecretStr
from sqlalchemy import JSON, BigInteger, Integer, MetaData, event, select, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
PATH = "/api/v1/integrations/risk-assessments/report"
SECRET = "test-only-integration-secret-32-characters"
def payload():
return {
"requestId": "PLM-D-FT-003-0001",
"sourceSystem": "dieya-plm",
"operator": {"dingtalkUnionId": "union-1", "dingtalkUserId": "ding-1", "name": "伪造姓名"},
"assessment": {
"subjectId": "D-FT-003",
"productName": "洗发水",
"productCategory": "洗发水/洗护类",
"materialType": "PET 瓶身 + PP 泵头",
"stageNew": "产品开发",
"asOfDate": "2026-01-15",
"supplier": None,
"factory": None,
},
}
def headers(body=b"", *, method="POST", query="", client="dieya-plm", nonce=None, timestamp=None):
timestamp = timestamp or str(int(time.time()))
nonce = nonce or uuid.uuid4().hex
message = timestamp.encode() + b"\n" + nonce.encode() + b"\n"
if method == "GET":
message = b"GET\n" + PATH.encode() + b"\n" + query.encode() + b"\n" + message
else:
message += body
return {
"Content-Type": "application/json",
"X-Client-Id": client,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": base64.b64encode(hmac.new(SECRET.encode(), message, hashlib.sha256).digest()).decode(),
"Idempotency-Key": "PLM-D-FT-003-0001",
}
async def post(http_client, data=None, **options):
body = json.dumps(data if data is not None else payload(), ensure_ascii=False).encode()
return await http_client.post(PATH, content=body, headers=headers(body, **options))
@pytest.fixture
async def integration_db(tmp_path, monkeypatch):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'integration.db'}")
@event.listens_for(engine.sync_engine, "connect")
def sqlite_advisory_stub(connection, _):
connection.create_function("pg_advisory_xact_lock", 1, lambda key: None)
schema = MetaData()
for table in Base.metadata.sorted_tables:
copied = table.to_metadata(schema)
for column in copied.columns:
if isinstance(column.type, JSONB):
column.type = JSON()
elif isinstance(column.type, BigInteger):
column.type = Integer()
async with engine.begin() as connection:
await connection.run_sync(schema.create_all)
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as session:
session.add(
UserAccount(
id=1,
emp_no="real-employee",
name="真实评估人",
role="user",
status="启用",
dingtalk_union_id="union-1",
dingtalk_id="ding-1",
)
)
session.add(RolePermission(role="user", func_key="risk.assess", can_operate=True, can_view=True))
session.add_all(
[
AnomalyCase(
id=1,
date=date(2026, 1, 10),
product_name="洗发水",
product_category="洗发露",
material_type="泵头",
stage_new="产品开发",
defect_level="C",
description="泵头漏液",
solution="密封验证",
import_fingerprint="case1",
),
AnomalyCase(
id=2,
date=date(2026, 1, 20),
product_name="洗发水",
product_category="洗发露",
material_type="泵头",
stage_new="产品开发",
defect_level="A",
description="未来记录",
import_fingerprint="case2",
),
]
)
await session.commit()
settings = SimpleNamespace(
integration_clients=SecretStr(json.dumps({"dieya-plm": SECRET, "other-system": SECRET})),
integration_operator_mappings=SecretStr("{}"),
integration_requests_per_minute=60,
integration_report_timeout_seconds=110,
)
monkeypatch.setattr(integration_auth, "get_settings", lambda: settings)
monkeypatch.setattr(integration_risk, "get_settings", lambda: settings)
async def suggestions(self, context):
return ["完成密封性验证。"]
monkeypatch.setattr(LLMClient, "generate_prevention_suggestions", suggestions)
app = create_app()
async def database():
async with sessions() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_db_session] = database
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
yield client, sessions, settings
await engine.dispose()
async def test_success_uses_cutoff_actual_user_and_seven_dimensions(integration_db):
client, sessions, _ = integration_db
response = await post(client)
assert response.status_code == 200, response.text
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert response.headers["x-risk-level"] in {"low", "moderately_low", "medium", "high", "very_high"}
assert "真实评估人" in response.text and "伪造姓名" not in response.text
assert "泵头" in response.text and "未来记录" not in response.text
assert response.headers["x-model-version"] == "database-seven-dimension-v1"
async with sessions() as session:
record = await session.scalar(select(IntegrationAssessment))
assert record.status == "completed"
assert len(record.result_data["dimensionScores"]) == 7
assert record.result_data["categoryWarning"]["anomalyCount"] == 1
assert record.result_data["similarCases"][0]["caseId"] == 1
assert record.result_data["score"] == float(response.headers["x-risk-score"])
async def test_replay_and_bad_signature_do_not_generate_reports(integration_db):
client, sessions, _ = integration_db
response = await post(client, nonce="same-nonce-123")
assert response.status_code == 200
assert (await post(client, nonce="same-nonce-123")).status_code == 401
body = json.dumps(payload()).encode()
signed = headers(body)
assert (await client.post(PATH, content=body + b" ", headers=signed)).status_code == 401
assert (await post(client, timestamp=str(int(time.time()) - 301))).status_code == 401
assert (await client.post(PATH, json=payload())).status_code == 401
async with sessions() as session:
assert len(list((await session.scalars(select(IntegrationAssessment))).all())) == 1
async def test_idempotency_query_isolation_and_query_signature(integration_db):
client, _, _ = integration_db
original = await post(client)
body = json.dumps(payload(), sort_keys=True, separators=(",", ":")).encode()
repeated = await client.post(PATH, content=body, headers=headers(body))
assert repeated.status_code == 200
assert repeated.text == original.text
assert repeated.headers["x-assessment-id"] == original.headers["x-assessment-id"]
changed = payload()
changed["assessment"]["materialType"] = "软管"
assert (await post(client, changed)).status_code == 409
query = "requestId=PLM-D-FT-003-0001"
retrieved = await client.get(PATH + "?" + query, headers=headers(method="GET", query=query))
assert retrieved.status_code == 200 and retrieved.text == original.text
missing = await client.get(PATH + "?" + query, headers=headers(method="GET", query=query, client="other-system"))
assert missing.status_code == 404
tampered = await client.get(PATH + "?requestId=OTHER", headers=headers(method="GET", query=query))
assert tampered.status_code == 401
@pytest.mark.parametrize("change", ["missing_category", "blank_material", "bad_stage", "bad_date", "extra_score"])
async def test_invalid_fields_return_json_422(integration_db, change):
client, _, _ = integration_db
data = payload()
if change == "missing_category":
del data["assessment"]["productCategory"]
elif change == "blank_material":
data["assessment"]["materialType"] = " "
elif change == "bad_stage":
data["assessment"]["stageNew"] = "未知阶段"
elif change == "bad_date":
data["assessment"]["asOfDate"] = "2099-01-01"
else:
data["assessment"]["score"] = 99
response = await post(client, data)
assert response.status_code == 422
assert response.json()["code"] == 422 and response.json()["data"] is None
@pytest.mark.parametrize("change", ["missing_user", "duplicate_union", "disabled_user", "no_permission", "mismatch"])
async def test_user_mapping_and_permissions(integration_db, change):
client, sessions, _ = integration_db
data = payload()
async with sessions() as session:
if change == "missing_user":
data["operator"]["dingtalkUnionId"] = "not-opened"
elif change == "duplicate_union":
session.add(
UserAccount(
emp_no="second",
name="重复映射",
role="admin",
status="启用",
dingtalk_union_id="union-1",
dingtalk_id="ding-2",
)
)
elif change == "disabled_user":
await session.execute(update(UserAccount).values(status="停用"))
elif change == "no_permission":
await session.execute(update(RolePermission).values(can_operate=False))
else:
data["operator"]["dingtalkUserId"] = "wrong-user"
await session.commit()
assert (await post(client, data)).status_code == 403
async with sessions() as session:
assert await session.scalar(select(IntegrationAssessment)) is None
async def test_rate_limit_has_retry_after(integration_db):
client, _, settings = integration_db
settings.integration_requests_per_minute = 1
assert (await post(client)).status_code == 200
response = await post(client)
assert response.status_code == 429 and response.headers["retry-after"] == "60"
async def test_failed_and_expired_processing_requests_can_retry(integration_db, monkeypatch):
client, sessions, _ = integration_db
original = integration_risk.ExternalRiskReportService.assess
async def fail(self, payload, as_of):
raise RuntimeError("provider secret must not be returned")
monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", fail)
failed = await post(client)
assert failed.status_code == 503 and "provider secret" not in failed.text
monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", original)
assert (await post(client)).status_code == 200
async with sessions() as session:
await session.execute(
update(IntegrationAssessment).values(
status="processing",
lease_until=datetime.now(UTC) + timedelta(seconds=100),
)
)
await session.commit()
pending = await post(client)
assert pending.status_code == 202 and pending.headers["retry-after"] == "5"
async with sessions() as session:
await session.execute(
update(IntegrationAssessment).values(
lease_until=datetime.now(UTC) - timedelta(seconds=1),
)
)
await session.commit()
assert (await post(client)).status_code == 200
async def test_unrecognized_category_and_ai_failure_are_explained(integration_db, monkeypatch):
client, _, _ = integration_db
async def unavailable(self, context):
raise RuntimeError("LLM down")
monkeypatch.setattr(LLMClient, "generate_prevention_suggestions", unavailable)
data = payload()
data["assessment"]["productCategory"] = "防晒"
data["assessment"]["productName"] = "<script>alert(1)</script>|[链接](javascript:test)"
response = await post(client, data)
assert response.status_code == 200
assert "无法归类" in response.text and "固定验证建议" in response.text
assert "<script>" not in response.text and "&lt;script&gt;" in response.text
async def test_nonce_is_consumed_even_when_body_invalid(integration_db):
client, sessions, _ = integration_db
data = payload()
del data["assessment"]["subjectId"]
assert (await post(client, data, nonce="invalid-body-nonce")).status_code == 422
assert (await post(client, nonce="invalid-body-nonce")).status_code == 401
async with sessions() as session:
assert await session.scalar(select(IntegrationNonce)) is not None
async def test_concurrent_same_request_does_not_start_another_assessment(integration_db, monkeypatch):
client, sessions, _ = integration_db
entered, release = asyncio.Event(), asyncio.Event()
original = integration_risk.ExternalRiskReportService.assess
calls = 0
async def blocked(self, payload, as_of):
nonlocal calls
calls += 1
entered.set()
await release.wait()
return await original(self, payload, as_of)
monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", blocked)
first = asyncio.create_task(post(client))
try:
await asyncio.wait_for(entered.wait(), timeout=5)
pending = await post(client)
assert pending.status_code == 202
query = "requestId=PLM-D-FT-003-0001"
queried = await client.get(PATH + "?" + query, headers=headers(method="GET", query=query))
assert queried.status_code == 202
finally:
release.set()
completed = await first
assert completed.status_code == 200 and calls == 1
async with sessions() as session:
assert len(list((await session.scalars(select(IntegrationAssessment))).all())) == 1
async def test_generation_timeout_is_retryable(integration_db, monkeypatch):
client, sessions, settings = integration_db
original = integration_risk.ExternalRiskReportService.assess
async def slow(self, payload, as_of):
await asyncio.sleep(1)
settings.integration_report_timeout_seconds = 0.01
monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", slow)
assert (await post(client)).status_code == 503
async with sessions() as session:
assert (await session.scalar(select(IntegrationAssessment))).status == "failed"
settings.integration_report_timeout_seconds = 110
monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", original)
assert (await post(client)).status_code == 200
async def test_completion_is_fenced_by_owner_token(integration_db):
client, sessions, _ = integration_db
assert (await post(client)).status_code == 200
async with sessions() as session:
record = await session.scalar(select(IntegrationAssessment))
record.status, record.owner_token = "processing", "new-owner"
await session.commit()
repository = IntegrationRiskRepository(session)
assert not await repository.finish(record.id, "old-owner", error="obsolete failure")
current = await repository.get("dieya-plm", "PLM-D-FT-003-0001")
assert current.status == "processing" and current.owner_token == "new-owner"
async def test_source_system_and_idempotency_header_must_match(integration_db):
client, _, _ = integration_db
data = payload()
data["sourceSystem"] = "other-system"
assert (await post(client, data)).status_code == 403
body = json.dumps(payload()).encode()
signed = headers(body)
signed["Idempotency-Key"] = "other-request"
assert (await client.post(PATH, content=body, headers=signed)).status_code == 422
async def test_openapi_exposes_request_contract(integration_db):
client, _, _ = integration_db
schema = (await client.get("/openapi.json")).json()
assert schema["paths"][PATH]["post"]["requestBody"]["required"]
assert "IntegrationRiskRequest" in schema["components"]["schemas"]
async def add_external_operator(sessions, settings):
async with sessions() as session:
session.add(UserAccount(emp_no="plm_superadmin", name="周子墨", role="user", status="启用"))
await session.commit()
settings.integration_operator_mappings = SecretStr(json.dumps({"dieya-plm": {"superadmin": "plm_superadmin"}}))
async def test_external_operator_can_generate_and_query_report(integration_db):
client, sessions, settings = integration_db
await add_external_operator(sessions, settings)
data = payload()
data["operator"] = {"externalUserId": "superadmin", "name": "伪造姓名"}
report = await post(client, data)
assert report.status_code == 200 and "周子墨" in report.text and "伪造姓名" not in report.text
query = "requestId=PLM-D-FT-003-0001"
fetched = await client.get(PATH + "?" + query, headers=headers(method="GET", query=query))
assert fetched.status_code == 200 and fetched.text == report.text
async with sessions() as session:
user = await session.scalar(select(UserAccount).where(UserAccount.emp_no == "plm_superadmin"))
record = await session.scalar(select(IntegrationAssessment))
assert user.role == "user" and user.password_hash is None and user.dingtalk_union_id is None
assert record.user_id == user.id
@pytest.mark.parametrize("change", ["unknown", "other_client", "disabled", "permission", "config_revoked"])
async def test_external_operator_scope_and_permission_are_enforced(integration_db, change):
client, sessions, settings = integration_db
await add_external_operator(sessions, settings)
data = payload()
data["operator"] = {"externalUserId": "superadmin"}
if change == "unknown":
data["operator"]["externalUserId"] = "unknown"
elif change == "other_client":
data["sourceSystem"] = "other-system"
elif change == "config_revoked":
settings.integration_operator_mappings = SecretStr("{}")
else:
async with sessions() as session:
if change == "disabled":
await session.execute(
update(UserAccount).where(UserAccount.emp_no == "plm_superadmin").values(status="停用")
)
else:
await session.execute(update(RolePermission).values(can_operate=False))
await session.commit()
assert (await post(client, data, client=data["sourceSystem"])).status_code == 403
@pytest.mark.parametrize(
"operator",
[
{},
{"externalUserId": "superadmin", "dingtalkUnionId": "union-1"},
{"externalUserId": "superadmin", "dingtalkUserId": "ding-1"},
{"externalUserId": " "},
],
)
async def test_operator_identity_must_be_explicit_and_unambiguous(integration_db, operator):
client, _, _ = integration_db
data = payload()
data["operator"] = operator
assert (await post(client, data)).status_code == 422
async def test_existing_dingtalk_idempotency_digest_is_preserved(integration_db):
client, sessions, _ = integration_db
assert (await post(client)).status_code == 200
from app.schemas.integration_risk import IntegrationRiskRequest
old_data = IntegrationRiskRequest.model_validate(payload()).model_dump(mode="json", by_alias=True)
old_data["operator"].pop("externalUserId")
old_digest = hashlib.sha256(
json.dumps(old_data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
async with sessions() as session:
record = await session.scalar(select(IntegrationAssessment))
assert record.request_hash == old_digest

View File

@ -21,6 +21,8 @@ def test_prd_core_tables_are_registered() -> None:
"anomaly_cases", "anomaly_cases",
"anomaly_case_suppliers", "anomaly_case_suppliers",
"conversations", "conversations",
"integration_assessments",
"integration_nonces",
"conversation_messages", "conversation_messages",
"operation_audits", "operation_audits",
"review_records", "review_records",

View File

@ -0,0 +1,77 @@
"""Client contract checks: exact signed bytes, gzip decoding, idempotency and report identity."""
import base64
import gzip
import hashlib
import hmac
import json
from urllib.parse import urlsplit
import httpx
import pytest
from scripts.test_plm_risk_report import DEFAULT_PAYLOAD, PLMReportTest
SECRET = "client-test-secret-at-least-32-characters"
URL = "http://test/api/v1/integrations/risk-assessments/report"
DIMENSIONS = ("供应商表现", "物料品类", "产品品类", "加工厂质量", "标准符合性", "节点阶段", "缺陷等级")
REPORT = (
"# 新品风险评估报告\n\n" + "\n".join(f"| {name} | 50 | 10% | default | 默认分 |" for name in DIMENSIONS)
).encode()
def test_client_sends_signed_snapshot_and_handles_compression(tmp_path):
payload = json.loads(DEFAULT_PAYLOAD.read_text(encoding="utf-8"))
methods, nonces, post_bodies = [], [], []
def handler(request):
methods.append(request.method)
timestamp, nonce = request.headers["X-Timestamp"], request.headers["X-Nonce"]
nonces.append(nonce)
prefix = timestamp.encode() + b"\n" + nonce.encode() + b"\n"
if request.method == "GET":
target = urlsplit(str(request.url))
message = b"GET\n" + target.path.encode() + b"\n" + target.query.encode() + b"\n" + prefix
assert request.content == b""
assert "Idempotency-Key" not in request.headers and "Content-Type" not in request.headers
else:
message = prefix + request.content
assert request.headers["Idempotency-Key"] == payload["requestId"]
post_bodies.append(request.content)
expected = base64.b64encode(hmac.new(SECRET.encode(), message, hashlib.sha256).digest()).decode()
assert request.headers["X-Signature"] == expected
if len(methods) == 1:
return httpx.Response(404, json={"code": 404, "message": "not found", "data": None})
return httpx.Response(
200,
headers={
"Content-Type": "text/markdown; charset=utf-8",
"Content-Encoding": "gzip",
"X-Request-Id": payload["requestId"],
"X-Assessment-Id": "2",
"X-Risk-Score": "44.00",
"X-Risk-Level": "medium",
"X-Model-Version": "database-seven-dimension-v1",
},
content=gzip.compress(REPORT),
)
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
result = PLMReportTest(client, URL, SECRET, payload, tmp_path).run()
assert result["success"] and result["score"] == 44 and result["dimensionCount"] == 7
assert methods == ["GET", "POST", "POST", "GET"]
assert len(set(nonces)) == 4
assert post_bodies[0] == post_bodies[1] == (tmp_path / "request.json").read_bytes()
assert (tmp_path / "report.md").read_bytes() == REPORT
assert SECRET not in json.dumps(result) and "x-signature" not in json.dumps(result).lower()
def test_client_rejects_report_for_different_request(tmp_path):
payload = json.loads(DEFAULT_PAYLOAD.read_text(encoding="utf-8"))
def handler(request):
return httpx.Response(200, headers={"Content-Type": "text/markdown", "X-Request-Id": "wrong"}, content=REPORT)
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
with pytest.raises(ValueError, match="X-Request-Id"):
PLMReportTest(client, URL, SECRET, payload, tmp_path).run()
assert not (tmp_path / "report.md").exists()