新增第三方风险评估报告接口
This commit is contained in:
parent
79ddd8c8e7
commit
6995fe1f97
11
.env.example
11
.env.example
@ -26,6 +26,17 @@ RISK_DINGTALK_APP_SECRET=
|
||||
RISK_DINGTALK_CORP_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 ====================
|
||||
# 图片调用 WebImage,PDF 调用 PaddleOCR-VL 文档解析;上传原件仅保存在本机目录。
|
||||
BAIDU_OCR_API_KEY=
|
||||
|
||||
@ -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 /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,不生成模拟回复。
|
||||
|
||||
架构说明见 [docs/architecture.md](docs/architecture.md)。
|
||||
|
||||
@ -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.dashboard import router as dashboard_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.monitoring import router as monitoring_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.include_router(health_router)
|
||||
api_router.include_router(integration_risk_router)
|
||||
api_router.include_router(model_config_router)
|
||||
api_router.include_router(monitoring_router)
|
||||
api_router.include_router(ocr_router)
|
||||
|
||||
218
app/api/v1/integration_risk.py
Normal file
218
app/api/v1/integration_risk.py
Normal 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)
|
||||
@ -25,6 +25,10 @@ class Settings(BaseSettings):
|
||||
dingtalk_app_secret: SecretStr | None = Field(default=None, repr=False)
|
||||
dingtalk_corp_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(
|
||||
default=None,
|
||||
repr=False,
|
||||
|
||||
@ -5,6 +5,8 @@ from app.models.entities import (
|
||||
AnomalyCaseSupplier,
|
||||
Conversation,
|
||||
ConversationMessage,
|
||||
IntegrationAssessment,
|
||||
IntegrationNonce,
|
||||
OperationAudit,
|
||||
PurchaseOrderLine,
|
||||
ReviewRecord,
|
||||
@ -13,8 +15,8 @@ from app.models.entities import (
|
||||
RiskModelConfig,
|
||||
RolePermission,
|
||||
StandardRule,
|
||||
SupplierProfile,
|
||||
SupplierMaster,
|
||||
SupplierProfile,
|
||||
UserAccount,
|
||||
)
|
||||
|
||||
@ -23,6 +25,8 @@ __all__ = [
|
||||
"AnomalyCaseSupplier",
|
||||
"Conversation",
|
||||
"ConversationMessage",
|
||||
"IntegrationAssessment",
|
||||
"IntegrationNonce",
|
||||
"OperationAudit",
|
||||
"ReviewRecord",
|
||||
"RiskAssessment",
|
||||
|
||||
@ -4,7 +4,20 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
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.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@ -106,7 +119,9 @@ class AnomalyCaseSupplier(Base):
|
||||
|
||||
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)
|
||||
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))
|
||||
material_type: Mapped[str | None] = mapped_column(String(200))
|
||||
source_text: Mapped[str | None] = mapped_column(Text)
|
||||
@ -252,6 +267,51 @@ class ConversationMessage(Base):
|
||||
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):
|
||||
"""Risk item submitted through form, voice, OCR, or import before approval."""
|
||||
|
||||
|
||||
120
app/repositories/integration_risk.py
Normal file
120
app/repositories/integration_risk.py
Normal 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
|
||||
61
app/schemas/integration_risk.py
Normal file
61
app/schemas/integration_risk.py
Normal 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
|
||||
217
app/services/external_risk_report.py
Normal file
217
app/services/external_risk_report.py
Normal 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
|
||||
168
app/services/integration_auth.py
Normal file
168
app/services/integration_auth.py
Normal 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
|
||||
140
app/services/markdown_reports.py
Normal file
140
app/services/markdown_reports.py
Normal 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)
|
||||
@ -53,6 +53,7 @@ class AnomalyCaseRAG:
|
||||
factory: str | None = None,
|
||||
stage: str | None = None,
|
||||
anomaly_type: str | None = None,
|
||||
rows: list[dict[str, Any]] | None = None,
|
||||
) -> list[RetrievedCase]:
|
||||
identity = await resolve_supplier(self.session, supplier)
|
||||
supplier_match = (
|
||||
@ -61,7 +62,7 @@ class AnomalyCaseRAG:
|
||||
else supplier
|
||||
)
|
||||
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,
|
||||
category=category,
|
||||
material=material,
|
||||
|
||||
53
docs/PLM接口测试脚本使用说明.md
Normal file
53
docs/PLM接口测试脚本使用说明.md
Normal 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 建议文本可能不同。
|
||||
21
docs/examples/plm-risk-request-20261009.json
Normal file
21
docs/examples/plm-risk-request-20261009.json
Normal 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"
|
||||
}
|
||||
}
|
||||
550
docs/外部系统风险评估报告接口.md
Normal file
550
docs/外部系统风险评估报告接口.md
Normal 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。
|
||||
49
migrations/versions/20261008_0022_integration_reports.py
Normal file
49
migrations/versions/20261008_0022_integration_reports.py
Normal 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")
|
||||
68
scripts/call_external_risk_report.py
Normal file
68
scripts/call_external_risk_report.py
Normal 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())
|
||||
177
scripts/setup_external_risk.py
Normal file
177
scripts/setup_external_risk.py
Normal 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())
|
||||
292
scripts/test_plm_risk_report.py
Normal file
292
scripts/test_plm_risk_report.py
Normal 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())
|
||||
43
tests/test_external_setup.py
Normal file
43
tests/test_external_setup.py
Normal 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
|
||||
494
tests/test_integration_risk.py
Normal file
494
tests/test_integration_risk.py
Normal 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 "<script>" 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
|
||||
@ -21,6 +21,8 @@ def test_prd_core_tables_are_registered() -> None:
|
||||
"anomaly_cases",
|
||||
"anomaly_case_suppliers",
|
||||
"conversations",
|
||||
"integration_assessments",
|
||||
"integration_nonces",
|
||||
"conversation_messages",
|
||||
"operation_audits",
|
||||
"review_records",
|
||||
|
||||
77
tests/test_plm_report_client.py
Normal file
77
tests/test_plm_report_client.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user