风险预警
This commit is contained in:
commit
ec8b83d015
19
.env.example
Normal file
19
.env.example
Normal file
@ -0,0 +1,19 @@
|
||||
RISK_APP_NAME=Dieya Risk Prediction Backend
|
||||
RISK_APP_VERSION=0.1.0
|
||||
RISK_ENVIRONMENT=development
|
||||
RISK_DEBUG=false
|
||||
RISK_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction
|
||||
RISK_LOG_LEVEL=INFO
|
||||
RISK_JWT_SECRET_KEY=CHANGE_THIS_IN_PRODUCTION
|
||||
RISK_JWT_ALGORITHM=HS256
|
||||
RISK_JWT_EXPIRE_MINUTES=480
|
||||
RISK_DINGTALK_APP_KEY=
|
||||
RISK_DINGTALK_APP_SECRET=
|
||||
RISK_DINGTALK_CORP_ID=
|
||||
RISK_DINGTALK_AGENT_ID=
|
||||
RISK_LLM_API_KEY=
|
||||
RISK_LLM_BASE_URL=https://api.openai.com/v1
|
||||
RISK_LLM_MODEL=gpt-4o-mini
|
||||
RISK_LLM_TIMEOUT_SECONDS=120
|
||||
RISK_LLM_TEMPERATURE=0.2
|
||||
RISK_CORS_ALLOW_ORIGINS=http://localhost:39158,http://127.0.0.1:39158
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.env
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
data/
|
||||
|
||||
12
.runtime-logs/backend.err.log
Normal file
12
.runtime-logs/backend.err.log
Normal file
@ -0,0 +1,12 @@
|
||||
INFO: Started server process [34308]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://127.0.0.1:18474 (Press CTRL+C to quit)
|
||||
D:\py_qoder\dieya_risk_prediction_backend\.venv\Lib\site-packages\jwt\api_jwt.py:147: InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
|
||||
return self._jws.encode(
|
||||
D:\py_qoder\dieya_risk_prediction_backend\.venv\Lib\site-packages\jwt\api_jwt.py:368: InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
|
||||
decoded = self.decode_complete(
|
||||
D:\py_qoder\dieya_risk_prediction_backend\.venv\Lib\site-packages\jwt\api_jwt.py:147: InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
|
||||
return self._jws.encode(
|
||||
D:\py_qoder\dieya_risk_prediction_backend\.venv\Lib\site-packages\jwt\api_jwt.py:368: InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
|
||||
decoded = self.decode_complete(
|
||||
26
.runtime-logs/backend.out.log
Normal file
26
.runtime-logs/backend.out.log
Normal file
@ -0,0 +1,26 @@
|
||||
INFO: 127.0.0.1:58350 - "POST /api/v1/auth/dev-login HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58355 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58521 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58524 - "GET /api/v1/health HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58522 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58523 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58886 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58889 - "GET /api/v1/health HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58887 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58888 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58941 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58946 - "GET /api/v1/health HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58944 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:58945 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:61473 - "GET /api/v1/auth/me HTTP/1.1" 401 Unauthorized
|
||||
INFO: 127.0.0.1:62724 - "POST /api/v1/auth/dev-login HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62735 - "GET /api/v1/health HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62734 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62739 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62750 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65438 - "POST /api/v1/auth/dev-login HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65451 - "GET /api/v1/health HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65462 - "GET /api/v1/standards?category=all&status=%E5%90%AF%E7%94%A8&page=1&pageSize=20 HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:65450 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65467 - "GET /api/v1/standards?category=all&status=%E5%90%AF%E7%94%A8&page=1&pageSize=20 HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:65516 - "GET /api/v1/standards HTTP/1.1" 404 Not Found
|
||||
44
README.md
Normal file
44
README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Dieya Risk Prediction Backend
|
||||
|
||||
本项目参考 `dy_finance_agent_backend` 的分层方式,使用 Python 3.11、FastAPI、Pydantic v2、SQLAlchemy 2.x Async、PostgreSQL 和 Alembic。
|
||||
|
||||
## 本地启动
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install -e ".[dev]"
|
||||
python -m uvicorn app.main:app --host 127.0.0.1 --port 18474
|
||||
```
|
||||
|
||||
创建本地密码账号(密码只保存哈希):
|
||||
|
||||
```powershell
|
||||
python scripts/create_user.py --emp-no admin001 --name 管理员 --role admin --password "请替换为强密码" --email admin@dieya.com
|
||||
```
|
||||
|
||||
钉钉免登需要额外配置 `RISK_DINGTALK_APP_KEY`、`RISK_DINGTALK_APP_SECRET`、`RISK_DINGTALK_CORP_ID` 和 `RISK_DINGTALK_AGENT_ID`;前端在钉钉容器中调用 `requestAuthCode()`,后端换码后签发本系统 JWT。
|
||||
|
||||
认证接口:`POST /api/v1/auth/login`、`GET /api/v1/auth/me`、`GET /api/v1/auth/dingtalk/config`、`POST /api/v1/auth/dingtalk/login`。
|
||||
|
||||
审核接口:`POST /api/v1/risk-items`、`GET /api/v1/risk-items/pending`、`POST /api/v1/risk-items/{id}/review`。
|
||||
|
||||
健康检查:`GET http://127.0.0.1:18474/api/v1/health`
|
||||
|
||||
数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。
|
||||
|
||||
导入 PRD 数据:
|
||||
|
||||
```powershell
|
||||
python -m alembic upgrade head
|
||||
python scripts/import_excel_data.py `
|
||||
--anomaly-file "C:\Users\Administrator\Desktop\异常数据汇总表.xlsx" `
|
||||
--standard-file "C:\Users\Administrator\Desktop\标准文件.xlsx"
|
||||
```
|
||||
|
||||
脚本只导入异常明细 Sheet 的 2,448 条记录,以及标准文件中的“包装/包材/法规/内料”四个分类 Sheet 共 219 条记录;“总表”不重复导入。脚本可重复执行,已导入行会按源文件、Sheet 和 Excel 行号自动跳过。原始异常文本和标准附件链接会完整保留,不做截断。
|
||||
|
||||
风险评估预览:`POST http://127.0.0.1:18474/api/v1/risk-predictions/preview`。指标值表示风险程度(0-100),接口返回的综合分按 PRD 转换为安全得分,分数越高风险越低;等级阈值为 80/60/40/20。
|
||||
|
||||
对话页真实 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)。
|
||||
37
alembic.ini
Normal file
37
alembic.ini
Normal file
@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = %(here)s/migrations
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
|
||||
2
app/__init__.py
Normal file
2
app/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Dieya risk prediction backend package."""
|
||||
|
||||
2
app/api/__init__.py
Normal file
2
app/api/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""API package."""
|
||||
|
||||
59
app/api/dependencies.py
Normal file
59
app/api/dependencies.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Shared FastAPI dependencies for database access and authorization."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.security import decode_access_token
|
||||
from app.database.session import get_db_session
|
||||
from app.models.entities import UserAccount
|
||||
from app.repositories.users import UserRepository
|
||||
|
||||
DatabaseSession = Annotated[AsyncSession, Depends(get_db_session)]
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
session: DatabaseSession,
|
||||
) -> UserAccount:
|
||||
"""Decode the JWT and re-check account status on every request."""
|
||||
|
||||
if credentials is None:
|
||||
raise AppException("缺少认证令牌", code=401, status_code=401)
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if not payload or not payload.get("sub"):
|
||||
raise AppException("无效或过期的认证令牌", code=401, status_code=401)
|
||||
try:
|
||||
user_id = int(payload["sub"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AppException("无效的认证令牌", code=401, status_code=401) from exc
|
||||
user = await UserRepository(session).get_by_id(user_id)
|
||||
if user is None or user.status != "启用":
|
||||
raise AppException("用户不存在或已停用", code=401, status_code=401)
|
||||
return user
|
||||
|
||||
|
||||
def require_permission(func_key: str, *, operate: bool = False) -> Callable[..., UserAccount]:
|
||||
"""Create an RBAC dependency checked against the current database state."""
|
||||
|
||||
async def dependency(
|
||||
current_user: Annotated[UserAccount, Depends(get_current_user)],
|
||||
session: DatabaseSession,
|
||||
) -> UserAccount:
|
||||
if current_user.role == "admin":
|
||||
return current_user
|
||||
statement_user = UserRepository(session)
|
||||
permissions = await statement_user.permissions_for_role(current_user.role, operate=operate)
|
||||
allowed = {
|
||||
permission.func_key for permission in permissions
|
||||
}
|
||||
if func_key not in allowed:
|
||||
raise AppException("无权执行该操作", code=403, status_code=403)
|
||||
return current_user
|
||||
|
||||
return dependency
|
||||
20
app/api/router.py
Normal file
20
app/api/router.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""Versioned API router composition."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
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.risk_items import router as risk_items_router
|
||||
from app.api.v1.risk_predictions import router as risk_predictions_router
|
||||
from app.api.v1.standards import router as standards_router
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(health_router)
|
||||
api_router.include_router(auth_router)
|
||||
api_router.include_router(conversations_router)
|
||||
api_router.include_router(dashboard_router)
|
||||
api_router.include_router(risk_items_router)
|
||||
api_router.include_router(risk_predictions_router)
|
||||
api_router.include_router(standards_router)
|
||||
2
app/api/v1/__init__.py
Normal file
2
app/api/v1/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""API version 1 package."""
|
||||
|
||||
72
app/api/v1/auth.py
Normal file
72
app/api/v1/auth.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Password, development, and DingTalk authentication endpoints."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.dependencies import DatabaseSession, get_current_user
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.models.entities import OperationAudit, UserAccount
|
||||
from app.repositories.users import UserRepository
|
||||
from app.schemas.auth import DingTalkConfigData, DingTalkLoginRequest, LoginRequest, TokenData, UserData
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.auth import AuthService
|
||||
from app.services.dingtalk_auth import DingTalkAuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=ApiResponse[TokenData])
|
||||
async def login(request: LoginRequest, session: DatabaseSession) -> ApiResponse[TokenData]:
|
||||
return ApiResponse(data=await AuthService(session).login(request.username, request.password))
|
||||
|
||||
|
||||
@router.get("/me", response_model=ApiResponse[UserData])
|
||||
async def me(
|
||||
current_user: Annotated[UserAccount, Depends(get_current_user)],
|
||||
session: DatabaseSession,
|
||||
) -> ApiResponse[UserData]:
|
||||
return ApiResponse(data=await AuthService(session).me(current_user))
|
||||
|
||||
|
||||
@router.get("/dingtalk/config", response_model=ApiResponse[DingTalkConfigData])
|
||||
async def dingtalk_config() -> ApiResponse[DingTalkConfigData]:
|
||||
return ApiResponse(data=DingTalkAuthService.config())
|
||||
|
||||
|
||||
@router.post("/dingtalk/login", response_model=ApiResponse[TokenData])
|
||||
async def dingtalk_login(request: DingTalkLoginRequest, session: DatabaseSession) -> ApiResponse[TokenData]:
|
||||
return ApiResponse(data=await DingTalkAuthService(session).login(request.auth_code))
|
||||
|
||||
|
||||
@router.post("/dev-login", response_model=ApiResponse[TokenData])
|
||||
async def dev_login(request: LoginRequest, session: DatabaseSession) -> ApiResponse[TokenData]:
|
||||
"""Development-only login helper used by local frontend verification."""
|
||||
|
||||
settings = get_settings()
|
||||
if settings.environment != "development":
|
||||
raise AppException("开发登录入口未启用", code=404, status_code=404)
|
||||
user = await UserRepository(session).get_by_dingtalk_id(f"local-{request.username}")
|
||||
if user is None:
|
||||
user = UserAccount(
|
||||
emp_no=f"dev_{request.username}"[:20],
|
||||
name="开发测试用户",
|
||||
role="admin",
|
||||
status="启用",
|
||||
dingtalk_id=f"local-{request.username}",
|
||||
password_hash=hash_password(request.password),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
session.add(OperationAudit(actor_user_id=user.id, actor_emp_no=user.emp_no, action="auth.dev_login"))
|
||||
return ApiResponse(
|
||||
data=TokenData(
|
||||
access_token=create_access_token(user.id, role=user.role),
|
||||
expires_in=settings.jwt_expire_minutes * 60,
|
||||
user=await AuthService(session).me(user),
|
||||
)
|
||||
)
|
||||
102
app/api/v1/conversations.py
Normal file
102
app/api/v1/conversations.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""Persistent assistant conversation endpoints."""
|
||||
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.dependencies import DatabaseSession, require_permission
|
||||
from app.models.entities import UserAccount
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.schemas.conversation import (
|
||||
ConversationCreateRequest,
|
||||
ConversationData,
|
||||
ConversationMessageCreateRequest,
|
||||
ConversationSummary,
|
||||
)
|
||||
from app.services.conversations import ConversationService
|
||||
|
||||
router = APIRouter(prefix="/conversations", tags=["conversations"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[list[ConversationSummary]])
|
||||
async def list_conversations(
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("risk.assess"))],
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
search: str | None = Query(default=None, max_length=120),
|
||||
) -> ApiResponse[list[ConversationSummary]]:
|
||||
return ApiResponse(data=await ConversationService(session).list(current_user, limit=limit, search=search))
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[ConversationData])
|
||||
async def create_conversation(
|
||||
payload: ConversationCreateRequest,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
|
||||
) -> ApiResponse[ConversationData]:
|
||||
return ApiResponse(data=await ConversationService(session).create(current_user, payload.title))
|
||||
|
||||
|
||||
@router.get("/{conversation_id}", response_model=ApiResponse[ConversationData])
|
||||
async def get_conversation(
|
||||
conversation_id: int,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("risk.assess"))],
|
||||
limit: int = Query(20, ge=1, le=20),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> ApiResponse[ConversationData]:
|
||||
return ApiResponse(
|
||||
data=await ConversationService(session).get(
|
||||
conversation_id,
|
||||
current_user,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/messages", response_model=ApiResponse[ConversationData])
|
||||
async def send_conversation_message(
|
||||
conversation_id: int,
|
||||
payload: ConversationMessageCreateRequest,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
|
||||
) -> ApiResponse[ConversationData]:
|
||||
return ApiResponse(
|
||||
data=await ConversationService(session).send(
|
||||
conversation_id,
|
||||
payload.content,
|
||||
current_user,
|
||||
message_type=payload.message_type,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/stream")
|
||||
async def stream_conversation_message(
|
||||
conversation_id: int,
|
||||
payload: ConversationMessageCreateRequest,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
|
||||
) -> StreamingResponse:
|
||||
service = ConversationService(session)
|
||||
|
||||
async def events():
|
||||
try:
|
||||
async for event in service.stream(
|
||||
conversation_id,
|
||||
payload.content,
|
||||
current_user,
|
||||
message_type=payload.message_type,
|
||||
):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc: # noqa: BLE001 - turn route errors into SSE events
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
events(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
22
app/api/v1/dashboard.py
Normal file
22
app/api/v1/dashboard.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Dashboard endpoints."""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.dependencies import DatabaseSession, require_permission
|
||||
from app.models.entities import UserAccount
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.schemas.dashboard import DashboardData
|
||||
from app.services.dashboard import DashboardService
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[DashboardData])
|
||||
async def get_dashboard(
|
||||
session: DatabaseSession,
|
||||
_current_user: Annotated[UserAccount, Depends(require_permission("risk.assess"))],
|
||||
year: Literal["all", "2024", "2025", "2026"] = Query("all"),
|
||||
) -> ApiResponse[DashboardData]:
|
||||
return ApiResponse(data=await DashboardService(session).overview(year))
|
||||
16
app/api/v1/health.py
Normal file
16
app/api/v1/health.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""Service health endpoint."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.schemas.common import ApiResponse, HealthData
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=ApiResponse[HealthData])
|
||||
async def health(settings: Annotated[Settings, Depends(get_settings)]) -> ApiResponse[HealthData]:
|
||||
return ApiResponse(data=HealthData(service=settings.app_name, version=settings.app_version))
|
||||
|
||||
59
app/api/v1/risk_items.py
Normal file
59
app/api/v1/risk_items.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Risk item submission and review endpoints."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.dependencies import DatabaseSession, require_permission
|
||||
from app.models.entities import UserAccount
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.schemas.risk_item import ReviewActionRequest, ReviewRecordData, RiskItemCreate, RiskItemData, RiskItemUpdate
|
||||
from app.services.risk_items import RiskItemService
|
||||
|
||||
router = APIRouter(prefix="/risk-items", tags=["risk-items"])
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[RiskItemData])
|
||||
async def create_risk_item(
|
||||
payload: RiskItemCreate,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("risk_item.submit", operate=True))],
|
||||
) -> ApiResponse[RiskItemData]:
|
||||
return ApiResponse(data=await RiskItemService(session).submit(payload, current_user))
|
||||
|
||||
|
||||
@router.get("/pending", response_model=ApiResponse[list[RiskItemData]])
|
||||
async def pending_risk_items(
|
||||
session: DatabaseSession,
|
||||
_current_user: Annotated[UserAccount, Depends(require_permission("risk_item.review"))],
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
source_type: str = Query("all"),
|
||||
status: str = Query("待审核"),
|
||||
) -> ApiResponse[list[RiskItemData]]:
|
||||
return ApiResponse(
|
||||
data=await RiskItemService(session).list_pending(
|
||||
limit,
|
||||
source_type=source_type,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{item_id}", response_model=ApiResponse[RiskItemData])
|
||||
async def update_risk_item(
|
||||
item_id: int,
|
||||
payload: RiskItemUpdate,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("risk_item.review", operate=True))],
|
||||
) -> ApiResponse[RiskItemData]:
|
||||
return ApiResponse(data=await RiskItemService(session).update(item_id, payload, current_user))
|
||||
|
||||
|
||||
@router.post("/{item_id}/review", response_model=ApiResponse[ReviewRecordData])
|
||||
async def review_risk_item(
|
||||
item_id: int,
|
||||
payload: ReviewActionRequest,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("risk_item.review", operate=True))],
|
||||
) -> ApiResponse[ReviewRecordData]:
|
||||
return ApiResponse(data=await RiskItemService(session).review(item_id, payload.action, payload.note, current_user))
|
||||
28
app/api/v1/risk_predictions.py
Normal file
28
app/api/v1/risk_predictions.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Risk prediction endpoints."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.dependencies import require_permission
|
||||
from app.models.entities import UserAccount
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.schemas.risk_prediction import ModelInfo, RiskAssessmentRequest, RiskAssessmentResult
|
||||
from app.services.risk_prediction import RiskPredictionService
|
||||
|
||||
router = APIRouter(prefix="/risk-predictions", tags=["risk-predictions"])
|
||||
|
||||
|
||||
@router.get("/model-info", response_model=ApiResponse[ModelInfo])
|
||||
async def model_info(
|
||||
_: Annotated[UserAccount, Depends(require_permission("risk.assess"))],
|
||||
) -> ApiResponse[ModelInfo]:
|
||||
return ApiResponse(data=RiskPredictionService.model_info())
|
||||
|
||||
|
||||
@router.post("/preview", response_model=ApiResponse[RiskAssessmentResult])
|
||||
async def preview_risk(
|
||||
request: RiskAssessmentRequest,
|
||||
_: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
|
||||
) -> ApiResponse[RiskAssessmentResult]:
|
||||
return ApiResponse(data=RiskPredictionService.preview(request))
|
||||
56
app/api/v1/standards.py
Normal file
56
app/api/v1/standards.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Judgment standard library endpoints."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.dependencies import DatabaseSession, require_permission
|
||||
from app.models.entities import UserAccount
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.schemas.standards import StandardRuleCreate, StandardRuleDetail, StandardRuleListData, StandardRuleUpdate
|
||||
from app.services.standards import StandardLibraryService
|
||||
|
||||
router = APIRouter(prefix="/standards", tags=["standards"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[StandardRuleListData])
|
||||
async def list_standards(
|
||||
session: DatabaseSession,
|
||||
_current_user: Annotated[UserAccount, Depends(require_permission("standard.read"))],
|
||||
keyword: str | None = Query(default=None, max_length=50),
|
||||
category: str = Query(default="all"),
|
||||
status: str = Query(default="启用"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> ApiResponse[StandardRuleListData]:
|
||||
return ApiResponse(data=await StandardLibraryService(session).list(
|
||||
keyword=keyword, category=category, status=status, page=page, page_size=page_size,
|
||||
))
|
||||
|
||||
|
||||
@router.get("/{rule_id}", response_model=ApiResponse[StandardRuleDetail])
|
||||
async def get_standard(
|
||||
rule_id: int,
|
||||
session: DatabaseSession,
|
||||
_current_user: Annotated[UserAccount, Depends(require_permission("standard.read"))],
|
||||
) -> ApiResponse[StandardRuleDetail]:
|
||||
return ApiResponse(data=await StandardLibraryService(session).get(rule_id))
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[StandardRuleDetail])
|
||||
async def create_standard(
|
||||
payload: StandardRuleCreate,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("standard.manage", operate=True))],
|
||||
) -> ApiResponse[StandardRuleDetail]:
|
||||
return ApiResponse(data=await StandardLibraryService(session).create(payload, current_user))
|
||||
|
||||
|
||||
@router.patch("/{rule_id}", response_model=ApiResponse[StandardRuleDetail])
|
||||
async def update_standard(
|
||||
rule_id: int,
|
||||
payload: StandardRuleUpdate,
|
||||
session: DatabaseSession,
|
||||
current_user: Annotated[UserAccount, Depends(require_permission("standard.manage", operate=True))],
|
||||
) -> ApiResponse[StandardRuleDetail]:
|
||||
return ApiResponse(data=await StandardLibraryService(session).update(rule_id, payload, current_user))
|
||||
2
app/core/__init__.py
Normal file
2
app/core/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Application core package."""
|
||||
|
||||
92
app/core/config.py
Normal file
92
app/core/config.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Environment-backed application settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import AliasChoices, Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Dieya Risk Prediction Backend"
|
||||
app_version: str = "0.1.0"
|
||||
environment: str = "development"
|
||||
debug: bool = False
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction",
|
||||
repr=False,
|
||||
)
|
||||
log_level: str = "INFO"
|
||||
jwt_secret_key: SecretStr = SecretStr("CHANGE_THIS_IN_PRODUCTION")
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = Field(default=480, gt=0)
|
||||
dingtalk_app_key: str = ""
|
||||
dingtalk_app_secret: SecretStr | None = Field(default=None, repr=False)
|
||||
dingtalk_corp_id: str = ""
|
||||
dingtalk_agent_id: str = ""
|
||||
llm_api_key: SecretStr | None = Field(
|
||||
default=None,
|
||||
repr=False,
|
||||
validation_alias=AliasChoices(
|
||||
"RISK_LLM_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"YUNWU_API_KEY",
|
||||
),
|
||||
)
|
||||
llm_base_url: str = Field(
|
||||
default="https://api.openai.com/v1",
|
||||
validation_alias=AliasChoices("RISK_LLM_BASE_URL", "LLM_BASE_URL", "OPENAI_API_BASE"),
|
||||
)
|
||||
llm_model: str = Field(
|
||||
default="gpt-4o-mini",
|
||||
validation_alias=AliasChoices("RISK_LLM_MODEL", "LLM_MODEL"),
|
||||
)
|
||||
llm_timeout_seconds: int = Field(
|
||||
default=120,
|
||||
gt=0,
|
||||
le=600,
|
||||
validation_alias=AliasChoices("RISK_LLM_TIMEOUT_SECONDS", "LLM_TIMEOUT"),
|
||||
)
|
||||
llm_temperature: float = Field(
|
||||
default=0.2,
|
||||
ge=0,
|
||||
le=2,
|
||||
validation_alias=AliasChoices("RISK_LLM_TEMPERATURE", "LLM_TEMPERATURE"),
|
||||
)
|
||||
cors_allow_origins: Annotated[tuple[str, ...], NoDecode] = (
|
||||
"http://localhost:39158",
|
||||
"http://127.0.0.1:39158",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_prefix="RISK_",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
def validate_database_url(cls, value: str) -> str:
|
||||
if not value.startswith("postgresql+asyncpg://"):
|
||||
raise ValueError("database_url must use the postgresql+asyncpg:// scheme")
|
||||
return value
|
||||
|
||||
@field_validator("llm_base_url")
|
||||
@classmethod
|
||||
def normalize_llm_base_url(cls, value: str) -> str:
|
||||
return value.rstrip("/")
|
||||
|
||||
@field_validator("cors_allow_origins", mode="before")
|
||||
@classmethod
|
||||
def split_cors_origins(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
value = tuple(item.strip() for item in value.split(",") if item.strip())
|
||||
if isinstance(value, (list, tuple)) and "*" in value:
|
||||
raise ValueError("cors_allow_origins must not contain '*' wildcard")
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
30
app/core/exceptions.py
Normal file
30
app/core/exceptions.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Application exceptions and response handler."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: int = 400,
|
||||
status_code: int = 400,
|
||||
details: Any = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.details = details
|
||||
|
||||
|
||||
async def app_exception_handler(_: Request, exc: AppException) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"code": exc.code, "message": exc.message, "data": exc.details},
|
||||
)
|
||||
|
||||
20
app/core/logging.py
Normal file
20
app/core/logging.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""Shared logging setup."""
|
||||
|
||||
import logging
|
||||
|
||||
LOGGER_NAME = "dieya_risk_prediction"
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> logging.Logger:
|
||||
logger = logging.getLogger(LOGGER_NAME)
|
||||
logger.setLevel(level.upper())
|
||||
logger.propagate = False
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
logger = configure_logging()
|
||||
|
||||
61
app/core/security.py
Normal file
61
app/core/security.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Password hashing and JWT helpers."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_PBKDF2_ALGORITHM = "sha256"
|
||||
_PBKDF2_ITERATIONS = 310_000
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using salted PBKDF2-HMAC."""
|
||||
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(_PBKDF2_ALGORITHM, password.encode(), salt, _PBKDF2_ITERATIONS)
|
||||
return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str | None) -> bool:
|
||||
"""Verify a password hash without revealing whether the account exists."""
|
||||
|
||||
if not encoded:
|
||||
return False
|
||||
try:
|
||||
scheme, iterations, salt_hex, digest_hex = encoded.split("$", 3)
|
||||
if scheme != "pbkdf2_sha256":
|
||||
return False
|
||||
candidate = hashlib.pbkdf2_hmac(
|
||||
_PBKDF2_ALGORITHM,
|
||||
password.encode(),
|
||||
bytes.fromhex(salt_hex),
|
||||
int(iterations),
|
||||
)
|
||||
return hmac.compare_digest(candidate.hex(), digest_hex)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(subject: int, *, role: str) -> str:
|
||||
settings = get_settings()
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
payload: dict[str, Any] = {"sub": str(subject), "role": role, "exp": expires_at}
|
||||
return jwt.encode(payload, settings.jwt_secret_key.get_secret_value(), algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any] | None:
|
||||
settings = get_settings()
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.jwt_secret_key.get_secret_value(),
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
2
app/database/__init__.py
Normal file
2
app/database/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Database package."""
|
||||
|
||||
39
app/database/base.py
Normal file
39
app/database/base.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""SQLAlchemy declarative base and common mixins."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
UUIDPrimaryKey = Annotated[
|
||||
UUID,
|
||||
mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True, default=uuid4),
|
||||
]
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
id: Mapped[UUIDPrimaryKey]
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
28
app/database/session.py
Normal file
28
app/database/session.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Async SQLAlchemy engine, session factory, and request transaction boundary."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
|
||||
def create_database_engine(settings: Settings | None = None) -> AsyncEngine:
|
||||
resolved_settings = settings or get_settings()
|
||||
return create_async_engine(resolved_settings.database_url, pool_pre_ping=True)
|
||||
|
||||
|
||||
engine = create_database_engine()
|
||||
SessionFactory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with SessionFactory() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
else:
|
||||
await session.commit()
|
||||
|
||||
2
app/domain/__init__.py
Normal file
2
app/domain/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Domain package."""
|
||||
|
||||
71
app/domain/risk_engine.py
Normal file
71
app/domain/risk_engine.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""Replaceable baseline risk scoring engine."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class RiskLevel(StrEnum):
|
||||
LOW = "low"
|
||||
MODERATELY_LOW = "moderately_low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
VERY_HIGH = "very_high"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IndicatorValue:
|
||||
name: str
|
||||
value: float
|
||||
weight: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FactorContribution:
|
||||
name: str
|
||||
value: float
|
||||
weight: float
|
||||
contribution: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RiskScore:
|
||||
score: float
|
||||
level: RiskLevel
|
||||
factors: tuple[FactorContribution, ...]
|
||||
|
||||
|
||||
class BaselineRiskEngine:
|
||||
"""Weighted safety-score baseline following the PRD scoring direction.
|
||||
|
||||
Input indicator values represent risk intensity (0 = no observed risk,
|
||||
100 = maximum risk). The public score is the inverse safety score, so a
|
||||
higher result always means a safer assessment.
|
||||
"""
|
||||
|
||||
model_version = "baseline-safety-v2"
|
||||
|
||||
def predict(self, indicators: list[IndicatorValue]) -> RiskScore:
|
||||
total_weight = sum(item.weight for item in indicators)
|
||||
factors = tuple(
|
||||
FactorContribution(
|
||||
name=item.name,
|
||||
value=item.value,
|
||||
weight=item.weight,
|
||||
contribution=round((100 - item.value) * item.weight / total_weight, 2),
|
||||
)
|
||||
for item in indicators
|
||||
)
|
||||
score = round(sum(item.contribution for item in factors), 2)
|
||||
return RiskScore(score=score, level=self._classify(score), factors=factors)
|
||||
|
||||
@staticmethod
|
||||
def _classify(score: float) -> RiskLevel:
|
||||
if score >= 80:
|
||||
return RiskLevel.LOW
|
||||
if score >= 60:
|
||||
return RiskLevel.MODERATELY_LOW
|
||||
if score >= 40:
|
||||
return RiskLevel.MEDIUM
|
||||
if score >= 20:
|
||||
return RiskLevel.HIGH
|
||||
return RiskLevel.VERY_HIGH
|
||||
34
app/main.py
Normal file
34
app/main.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""FastAPI application factory."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.exceptions import AppException, app_exception_handler
|
||||
from app.core.logging import configure_logging
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
resolved_settings = settings or get_settings()
|
||||
configure_logging(resolved_settings.log_level)
|
||||
|
||||
application = FastAPI(
|
||||
title=resolved_settings.app_name,
|
||||
version=resolved_settings.app_version,
|
||||
debug=resolved_settings.debug,
|
||||
)
|
||||
application.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=list(resolved_settings.cors_allow_origins),
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "Accept"],
|
||||
)
|
||||
application.add_exception_handler(AppException, app_exception_handler)
|
||||
application.include_router(api_router)
|
||||
return application
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
29
app/models/__init__.py
Normal file
29
app/models/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""ORM model exports."""
|
||||
|
||||
from app.models.entities import (
|
||||
AnomalyCase,
|
||||
Conversation,
|
||||
ConversationMessage,
|
||||
OperationAudit,
|
||||
ReviewRecord,
|
||||
RiskAssessment,
|
||||
RiskItem,
|
||||
RolePermission,
|
||||
StandardRule,
|
||||
SupplierProfile,
|
||||
UserAccount,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AnomalyCase",
|
||||
"Conversation",
|
||||
"ConversationMessage",
|
||||
"OperationAudit",
|
||||
"ReviewRecord",
|
||||
"RiskAssessment",
|
||||
"RiskItem",
|
||||
"RolePermission",
|
||||
"StandardRule",
|
||||
"SupplierProfile",
|
||||
"UserAccount",
|
||||
]
|
||||
349
app/models/entities.py
Normal file
349
app/models/entities.py
Normal file
@ -0,0 +1,349 @@
|
||||
"""PRD-defined core PostgreSQL models for the risk prediction platform."""
|
||||
|
||||
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, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.base import Base
|
||||
|
||||
|
||||
class AnomalyCase(Base):
|
||||
"""Approved historical anomaly case used by the experience engine."""
|
||||
|
||||
__tablename__ = "anomaly_cases"
|
||||
__table_args__ = (
|
||||
Index("ix_anomaly_cases_date", "date"),
|
||||
Index("ix_anomaly_cases_product_category", "product_category"),
|
||||
Index("ix_anomaly_cases_supplier", "supplier"),
|
||||
Index("ix_anomaly_cases_factory", "factory"),
|
||||
Index("ix_anomaly_cases_material_type", "material_type"),
|
||||
Index("ix_anomaly_cases_defect_level", "defect_level"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
date: Mapped[date | None] = mapped_column(Date)
|
||||
product_code: Mapped[str | None] = mapped_column(String(100))
|
||||
product_name: Mapped[str | None] = mapped_column(String(500))
|
||||
brand: Mapped[str | None] = mapped_column(String(100))
|
||||
specification: Mapped[str | None] = mapped_column(String(100))
|
||||
product_category: Mapped[str | None] = mapped_column(String(100))
|
||||
stage_new: Mapped[str | None] = mapped_column(String(100))
|
||||
stage_old: Mapped[str | None] = mapped_column(String(100))
|
||||
anomaly_type: Mapped[str | None] = mapped_column(String(200))
|
||||
material_type: Mapped[str | None] = mapped_column(String(200))
|
||||
supplier: Mapped[str | None] = mapped_column(String(300))
|
||||
factory: Mapped[str | None] = mapped_column(String(300))
|
||||
person: Mapped[str | None] = mapped_column(String(200))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
root_cause: Mapped[str | None] = mapped_column(Text)
|
||||
solution: Mapped[str | None] = mapped_column(Text)
|
||||
result: Mapped[str | None] = mapped_column(Text)
|
||||
responsible: Mapped[str | None] = mapped_column(String(300))
|
||||
defect_level: Mapped[str | None] = mapped_column(String(10))
|
||||
is_resolved: Mapped[str | None] = mapped_column(Text)
|
||||
source_file: Mapped[str | None] = mapped_column(String(300))
|
||||
source_type: Mapped[str | None] = mapped_column(String(30))
|
||||
remark: Mapped[str | None] = mapped_column(Text)
|
||||
import_fingerprint: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
standard_rule_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("standard_rules.id", ondelete="SET NULL"),
|
||||
)
|
||||
# pgvector is not installed on the target PostgreSQL server. Store vectors as JSONB until it is available.
|
||||
embedding: Mapped[list[float] | None] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
standard_rule: Mapped["StandardRule | None"] = relationship(back_populates="anomaly_cases")
|
||||
review_records: Mapped[list["ReviewRecord"]] = relationship(back_populates="case")
|
||||
|
||||
|
||||
class SupplierProfile(Base):
|
||||
"""Aggregated supplier risk profile."""
|
||||
|
||||
__tablename__ = "supplier_profiles"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
supplier_name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
total_anomalies: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
frequency_coef: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("1.00"), server_default="1.00")
|
||||
tier: Mapped[str | None] = mapped_column(String(1))
|
||||
category_breakdown: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False)
|
||||
product_cat_breakdown: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB,
|
||||
default=dict,
|
||||
server_default="{}",
|
||||
nullable=False,
|
||||
)
|
||||
stage_breakdown: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False)
|
||||
monthly_trend: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSONB,
|
||||
default=list,
|
||||
server_default="[]",
|
||||
nullable=False,
|
||||
)
|
||||
risk_score: Mapped[int | None] = mapped_column(Integer)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class RiskAssessment(Base):
|
||||
"""A user-triggered risk assessment and its structured result."""
|
||||
|
||||
__tablename__ = "risk_assessments"
|
||||
__table_args__ = (
|
||||
Index("ix_risk_assessments_user_id", "user_id"),
|
||||
Index("ix_risk_assessments_created_at", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(50))
|
||||
product_name: Mapped[str | None] = mapped_column(String(200))
|
||||
params: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False)
|
||||
risk_score: Mapped[int | None] = mapped_column(Integer)
|
||||
risk_level: Mapped[str | None] = mapped_column(String(10))
|
||||
dimension_scores: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False)
|
||||
similar_cases: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False)
|
||||
ai_suggestions: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
"""A persistent assistant conversation owned by one authenticated user."""
|
||||
|
||||
__tablename__ = "conversations"
|
||||
__table_args__ = (
|
||||
Index("ix_conversations_user_updated", "user_id", "updated_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
messages: Mapped[list["ConversationMessage"]] = relationship(
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ConversationMessage.created_at",
|
||||
)
|
||||
|
||||
|
||||
class ConversationMessage(Base):
|
||||
"""One user or assistant message, including optional structured results."""
|
||||
|
||||
__tablename__ = "conversation_messages"
|
||||
__table_args__ = (
|
||||
Index("ix_conversation_messages_conversation_created", "conversation_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
conversation_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
message_type: Mapped[str] = mapped_column(String(20), default="text", server_default="text", nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
structured_data: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
conversation: Mapped[Conversation] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class RiskItem(Base):
|
||||
"""Risk item submitted through form, voice, OCR, or import before approval."""
|
||||
|
||||
__tablename__ = "risk_items"
|
||||
__table_args__ = (
|
||||
Index("ix_risk_items_status", "status"),
|
||||
Index("ix_risk_items_submitted_by", "submitted_by"),
|
||||
Index("ix_risk_items_submitted_at", "submitted_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
source_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
source_file_id: Mapped[str | None] = mapped_column(String(50))
|
||||
product_name: Mapped[str | None] = mapped_column(String(200))
|
||||
product_category: Mapped[str | None] = mapped_column(String(50))
|
||||
supplier: Mapped[str | None] = mapped_column(String(100))
|
||||
factory: Mapped[str | None] = mapped_column(String(100))
|
||||
stage_new: Mapped[str | None] = mapped_column(String(50))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
defect_level: Mapped[str | None] = mapped_column(String(5))
|
||||
solution: Mapped[str | None] = mapped_column(Text)
|
||||
photos: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False)
|
||||
submitted_by: Mapped[str | None] = mapped_column(String(50))
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status: Mapped[str] = mapped_column(String(20), default="待预审", server_default="待预审", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
review_records: Mapped[list["ReviewRecord"]] = relationship(
|
||||
back_populates="risk_item",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ReviewRecord(Base):
|
||||
"""Immutable review trail for a submitted risk item."""
|
||||
|
||||
__tablename__ = "review_records"
|
||||
__table_args__ = (
|
||||
Index("ix_review_records_risk_item_id", "risk_item_id"),
|
||||
Index("ix_review_records_reviewer", "reviewer"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
risk_item_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("risk_items.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
pre_review_result: Mapped[str | None] = mapped_column(String(20))
|
||||
pre_review_note: Mapped[str | None] = mapped_column(Text)
|
||||
reviewer: Mapped[str | None] = mapped_column(String(50))
|
||||
review_action: Mapped[str | None] = mapped_column(String(20))
|
||||
review_note: Mapped[str | None] = mapped_column(Text)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
case_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("anomaly_cases.id", ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
risk_item: Mapped[RiskItem] = relationship(back_populates="review_records")
|
||||
case: Mapped[AnomalyCase | None] = relationship(back_populates="review_records")
|
||||
|
||||
|
||||
class StandardRule(Base):
|
||||
"""Four-level judgment standard used by the rule engine."""
|
||||
|
||||
__tablename__ = "standard_rules"
|
||||
__table_args__ = (
|
||||
Index("ix_standard_rules_categories", "category_lv1", "category_lv2", "category_lv3"),
|
||||
Index("ix_standard_rules_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
category_lv1: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
category_lv2: Mapped[str | None] = mapped_column(String(60))
|
||||
category_lv3: Mapped[str | None] = mapped_column(String(60))
|
||||
risk_point: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
judge_standard: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
regulation: Mapped[str | None] = mapped_column(String(300))
|
||||
attachment: Mapped[str | None] = mapped_column(String(500))
|
||||
status: Mapped[str] = mapped_column(String(10), default="启用", server_default="启用", nullable=False)
|
||||
import_fingerprint: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
anomaly_cases: Mapped[list[AnomalyCase]] = relationship(back_populates="standard_rule")
|
||||
|
||||
|
||||
class UserAccount(Base):
|
||||
"""Enterprise user account and RBAC role assignment."""
|
||||
|
||||
__tablename__ = "user_accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
emp_no: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(128), unique=True)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
dept: Mapped[str | None] = mapped_column(String(50))
|
||||
position: Mapped[str | None] = mapped_column(String(50))
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(String(10), default="启用", server_default="启用", nullable=False)
|
||||
login_failed_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
dingtalk_id: Mapped[str | None] = mapped_column(String(50), unique=True)
|
||||
dingtalk_union_id: Mapped[str | None] = mapped_column(String(64))
|
||||
dingtalk_avatar: Mapped[str | None] = mapped_column(String(512))
|
||||
dingtalk_job_number: Mapped[str | None] = mapped_column(String(64))
|
||||
dingtalk_title: Mapped[str | None] = mapped_column(String(128))
|
||||
dingtalk_dept_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
dingtalk_is_dept_manager: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
server_default="false",
|
||||
nullable=False,
|
||||
)
|
||||
dingtalk_role_codes: Mapped[str | None] = mapped_column(String(512))
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
extra_perms: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""Function and data-level permissions assigned to a role."""
|
||||
|
||||
__tablename__ = "role_permissions"
|
||||
__table_args__ = (
|
||||
Index("uq_role_permissions_role_func", "role", "func_key", unique=True),
|
||||
Index("ix_role_permissions_role", "role"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
func_key: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
data_level: Mapped[str] = mapped_column(String(10), default="L1", server_default="L1", nullable=False)
|
||||
can_view: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False)
|
||||
can_operate: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class OperationAudit(Base):
|
||||
"""Append-only audit record for authentication, permission, and review actions."""
|
||||
|
||||
__tablename__ = "operation_audits"
|
||||
__table_args__ = (
|
||||
Index("ix_operation_audits_actor_created", "actor_user_id", "created_at"),
|
||||
Index("ix_operation_audits_action_created", "action", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
actor_emp_no: Mapped[str | None] = mapped_column(String(20))
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
resource_type: Mapped[str | None] = mapped_column(String(64))
|
||||
resource_id: Mapped[str | None] = mapped_column(String(128))
|
||||
result: Mapped[str] = mapped_column(String(20), default="success", server_default="success", nullable=False)
|
||||
audit_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata",
|
||||
JSONB,
|
||||
default=dict,
|
||||
server_default="{}",
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
8
app/models/risk_prediction.py
Normal file
8
app/models/risk_prediction.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""Compatibility export for the PRD risk assessment model."""
|
||||
|
||||
from app.models.entities import RiskAssessment
|
||||
|
||||
RiskPrediction = RiskAssessment
|
||||
|
||||
__all__ = ["RiskAssessment", "RiskPrediction"]
|
||||
|
||||
6
app/repositories/__init__.py
Normal file
6
app/repositories/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Repository exports."""
|
||||
|
||||
from app.repositories.risk_prediction import RiskPredictionRepository
|
||||
|
||||
__all__ = ["RiskPredictionRepository"]
|
||||
|
||||
25
app/repositories/base.py
Normal file
25
app/repositories/base.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Generic async repository primitives."""
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.base import Base
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelT]):
|
||||
def __init__(self, session: AsyncSession, model: type[ModelT]) -> None:
|
||||
self.session = session
|
||||
self.model = model
|
||||
|
||||
async def add(self, entity: ModelT) -> ModelT:
|
||||
self.session.add(entity)
|
||||
await self.session.flush()
|
||||
return entity
|
||||
|
||||
async def get(self, entity_id: UUID) -> ModelT | None:
|
||||
return await self.session.get(self.model, entity_id)
|
||||
|
||||
74
app/repositories/conversations.py
Normal file
74
app/repositories/conversations.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Persistence queries for user-owned assistant conversations."""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.entities import Conversation, ConversationMessage
|
||||
|
||||
|
||||
class ConversationRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_user(
|
||||
self,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 50,
|
||||
search: str | None = None,
|
||||
) -> list[Conversation]:
|
||||
filters = [Conversation.user_id == user_id]
|
||||
if search:
|
||||
filters.append(Conversation.title.ilike(f"%{search}%"))
|
||||
statement = (
|
||||
select(Conversation)
|
||||
.where(*filters)
|
||||
.options(selectinload(Conversation.messages))
|
||||
.order_by(Conversation.updated_at.desc(), Conversation.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(await self.session.scalars(statement))
|
||||
|
||||
async def get_page_for_user(
|
||||
self,
|
||||
conversation_id: int,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> tuple[Conversation, list[ConversationMessage], int] | None:
|
||||
conversation = await self.session.scalar(
|
||||
select(Conversation)
|
||||
.where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.user_id == user_id,
|
||||
)
|
||||
.options(selectinload(Conversation.messages))
|
||||
)
|
||||
if conversation is None:
|
||||
return None
|
||||
total = await self.session.scalar(
|
||||
select(func.count()).select_from(ConversationMessage).where(
|
||||
ConversationMessage.conversation_id == conversation_id,
|
||||
)
|
||||
)
|
||||
messages = list(
|
||||
await self.session.scalars(
|
||||
select(ConversationMessage)
|
||||
.where(ConversationMessage.conversation_id == conversation_id)
|
||||
.order_by(ConversationMessage.created_at.desc(), ConversationMessage.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
messages.reverse()
|
||||
return conversation, messages, int(total or 0)
|
||||
|
||||
async def get_for_user(self, conversation_id: int, user_id: int) -> Conversation | None:
|
||||
statement = (
|
||||
select(Conversation)
|
||||
.where(Conversation.id == conversation_id, Conversation.user_id == user_id)
|
||||
.options(selectinload(Conversation.messages))
|
||||
)
|
||||
return await self.session.scalar(statement)
|
||||
18
app/repositories/risk_prediction.py
Normal file
18
app/repositories/risk_prediction.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Risk assessment persistence operations."""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.entities import RiskAssessment
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class RiskPredictionRepository(BaseRepository[RiskAssessment]):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
super().__init__(session, RiskAssessment)
|
||||
|
||||
async def list_recent(self, *, limit: int = 20) -> list[RiskAssessment]:
|
||||
statement = select(RiskAssessment).order_by(RiskAssessment.created_at.desc()).limit(limit)
|
||||
result = await self.session.scalars(statement)
|
||||
return list(result)
|
||||
|
||||
33
app/repositories/users.py
Normal file
33
app/repositories/users.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""User and RBAC repository queries."""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.entities import RolePermission, UserAccount
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, user_id: int) -> UserAccount | None:
|
||||
return await self.session.get(UserAccount, user_id)
|
||||
|
||||
async def get_by_username(self, username: str) -> UserAccount | None:
|
||||
statement = select(UserAccount).where(
|
||||
(UserAccount.emp_no == username) | (UserAccount.email == username)
|
||||
)
|
||||
return await self.session.scalar(statement)
|
||||
|
||||
async def get_by_dingtalk_id(self, dingtalk_id: str) -> UserAccount | None:
|
||||
statement = select(UserAccount).where(UserAccount.dingtalk_id == dingtalk_id)
|
||||
return await self.session.scalar(statement)
|
||||
|
||||
async def permissions_for_role(self, role: str, *, operate: bool | None = None) -> list[RolePermission]:
|
||||
filters = [RolePermission.role == role]
|
||||
if operate is True:
|
||||
filters.append(RolePermission.can_operate.is_(True))
|
||||
elif operate is False:
|
||||
filters.append(RolePermission.can_view.is_(True))
|
||||
statement = select(RolePermission).where(*filters)
|
||||
return list(await self.session.scalars(statement))
|
||||
2
app/schemas/__init__.py
Normal file
2
app/schemas/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Pydantic schema package."""
|
||||
|
||||
44
app/schemas/auth.py
Normal file
44
app/schemas/auth.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Authentication and current-user API contracts."""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import ApiSchema
|
||||
|
||||
|
||||
class LoginRequest(ApiSchema):
|
||||
username: str = Field(min_length=1, max_length=128)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class DingTalkLoginRequest(ApiSchema):
|
||||
auth_code: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class UserData(ApiSchema):
|
||||
id: int
|
||||
emp_no: str
|
||||
email: str | None = None
|
||||
name: str
|
||||
dept: str | None = None
|
||||
position: str | None = None
|
||||
role: str
|
||||
status: str
|
||||
dingtalk_id: str | None = None
|
||||
dingtalk_job_number: str | None = None
|
||||
dingtalk_title: str | None = None
|
||||
dingtalk_dept_id: int | None = None
|
||||
dingtalk_is_dept_manager: bool = False
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TokenData(ApiSchema):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: UserData
|
||||
|
||||
|
||||
class DingTalkConfigData(ApiSchema):
|
||||
corp_id: str
|
||||
app_key: str
|
||||
agent_id: str
|
||||
30
app/schemas/common.py
Normal file
30
app/schemas/common.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Shared API response schemas."""
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
def to_camel(value: str) -> str:
|
||||
head, *tail = value.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
|
||||
class ApiSchema(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
DataT = TypeVar("DataT")
|
||||
|
||||
|
||||
class ApiResponse(ApiSchema, Generic[DataT]):
|
||||
code: int = 200
|
||||
message: str = "success"
|
||||
data: DataT | None = Field(default=None)
|
||||
|
||||
|
||||
class HealthData(ApiSchema):
|
||||
status: str = "ok"
|
||||
service: str
|
||||
version: str
|
||||
|
||||
55
app/schemas/conversation.py
Normal file
55
app/schemas/conversation.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Persistent assistant conversation contracts."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.domain.risk_engine import RiskLevel
|
||||
from app.schemas.common import ApiSchema
|
||||
from app.schemas.risk_prediction import RiskAssessmentResult
|
||||
|
||||
|
||||
class ConversationCreateRequest(ApiSchema):
|
||||
title: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class ConversationMessageCreateRequest(ApiSchema):
|
||||
content: str = Field(min_length=1, max_length=2000)
|
||||
message_type: Literal["text", "image", "file", "audio"] = "text"
|
||||
|
||||
|
||||
class ConversationListQuery(ApiSchema):
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class ConversationMessageData(ApiSchema):
|
||||
id: int
|
||||
role: str
|
||||
message_type: Literal["text", "image", "file", "audio"] = "text"
|
||||
content: str
|
||||
risk_result: RiskAssessmentResult | None = None
|
||||
response_card: dict | None = None
|
||||
semantic: dict | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ConversationStreamEvent(ApiSchema):
|
||||
type: Literal["start", "semantic", "delta", "done", "error"]
|
||||
content: str | None = None
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
class ConversationSummary(ApiSchema):
|
||||
id: int
|
||||
title: str
|
||||
message_count: int
|
||||
last_risk_level: RiskLevel | None = None
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ConversationData(ConversationSummary):
|
||||
messages: list[ConversationMessageData]
|
||||
messages_total: int = 0
|
||||
messages_offset: int = 0
|
||||
has_more_messages: bool = False
|
||||
74
app/schemas/dashboard.py
Normal file
74
app/schemas/dashboard.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Dashboard aggregation response contracts."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import ApiSchema
|
||||
|
||||
|
||||
class DashboardMetric(ApiSchema):
|
||||
value: int | float | None = None
|
||||
label: str
|
||||
change_percent: float | None = None
|
||||
|
||||
|
||||
class DashboardStage(ApiSchema):
|
||||
name: str
|
||||
count: int
|
||||
share: float
|
||||
risk_level: str
|
||||
|
||||
|
||||
class DashboardCategory(ApiSchema):
|
||||
name: str
|
||||
count: int
|
||||
share: float
|
||||
risk_level: str
|
||||
top_material: str | None = None
|
||||
|
||||
|
||||
class DashboardMatrixRow(ApiSchema):
|
||||
name: str
|
||||
party_type: str
|
||||
categories: dict[str, int]
|
||||
cooperation_total: int
|
||||
anomaly_count: int
|
||||
anomaly_rate: float
|
||||
tier: str
|
||||
|
||||
|
||||
class DashboardDefect(ApiSchema):
|
||||
level: str
|
||||
label: str
|
||||
count: int
|
||||
share: float
|
||||
|
||||
|
||||
class DashboardSupplierRank(ApiSchema):
|
||||
name: str
|
||||
party_type: str
|
||||
count: int
|
||||
tier: str
|
||||
top_problem: str | None = None
|
||||
|
||||
|
||||
class DashboardTrendPoint(ApiSchema):
|
||||
month: str
|
||||
label: str
|
||||
count: int
|
||||
|
||||
|
||||
class DashboardData(ApiSchema):
|
||||
year: Literal["all", "2024", "2025", "2026"]
|
||||
total_cases: int
|
||||
metrics: list[DashboardMetric] = Field(default_factory=list)
|
||||
stages: list[DashboardStage] = Field(default_factory=list)
|
||||
categories: list[DashboardCategory] = Field(default_factory=list)
|
||||
matrix_categories: list[str] = Field(default_factory=list)
|
||||
matrix: list[DashboardMatrixRow] = Field(default_factory=list)
|
||||
defects: list[DashboardDefect] = Field(default_factory=list)
|
||||
supplier_ranking: list[DashboardSupplierRank] = Field(default_factory=list)
|
||||
trend: list[DashboardTrendPoint] = Field(default_factory=list)
|
||||
insight: str
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
69
app/schemas/risk_item.py
Normal file
69
app/schemas/risk_item.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Risk item submission and review contracts."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import ApiSchema
|
||||
|
||||
|
||||
class RiskItemCreate(ApiSchema):
|
||||
source_type: str = Field(default="表单", min_length=1, max_length=20)
|
||||
source_file_id: str | None = Field(default=None, max_length=50)
|
||||
product_name: str = Field(min_length=1, max_length=200)
|
||||
product_category: str | None = Field(default=None, max_length=100)
|
||||
supplier: str | None = Field(default=None, max_length=300)
|
||||
factory: str | None = Field(default=None, max_length=300)
|
||||
stage_new: str | None = Field(default=None, max_length=100)
|
||||
description: str = Field(min_length=5)
|
||||
defect_level: str | None = Field(default=None, max_length=10)
|
||||
solution: str | None = None
|
||||
photos: list[str] = Field(default_factory=list, max_length=10)
|
||||
|
||||
|
||||
class RiskItemUpdate(ApiSchema):
|
||||
product_name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
product_category: str | None = Field(default=None, max_length=100)
|
||||
supplier: str | None = Field(default=None, max_length=300)
|
||||
factory: str | None = Field(default=None, max_length=300)
|
||||
stage_new: str | None = Field(default=None, max_length=100)
|
||||
description: str | None = Field(default=None, min_length=5)
|
||||
defect_level: str | None = Field(default=None, max_length=10)
|
||||
solution: str | None = None
|
||||
photos: list[str] | None = Field(default=None, max_length=10)
|
||||
|
||||
|
||||
class RiskItemData(ApiSchema):
|
||||
id: int
|
||||
source_type: str
|
||||
source_file_id: str | None
|
||||
product_name: str | None
|
||||
product_category: str | None
|
||||
supplier: str | None
|
||||
factory: str | None
|
||||
stage_new: str | None
|
||||
description: str | None
|
||||
defect_level: str | None
|
||||
solution: str | None
|
||||
photos: list[str]
|
||||
submitted_by: str | None
|
||||
submitted_at: datetime | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReviewActionRequest(ApiSchema):
|
||||
action: str = Field(pattern="^(通过|驳回)$")
|
||||
note: str = Field(min_length=2, max_length=2000)
|
||||
|
||||
|
||||
class ReviewRecordData(ApiSchema):
|
||||
id: int
|
||||
risk_item_id: int
|
||||
pre_review_result: str | None
|
||||
pre_review_note: str | None
|
||||
reviewer: str | None
|
||||
review_action: str | None
|
||||
review_note: str | None
|
||||
reviewed_at: datetime | None
|
||||
case_id: int | None
|
||||
91
app/schemas/risk_prediction.py
Normal file
91
app/schemas/risk_prediction.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""Risk prediction request and response contracts."""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.domain.risk_engine import RiskLevel
|
||||
from app.schemas.common import ApiSchema
|
||||
|
||||
|
||||
class RiskIndicator(ApiSchema):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
value: float = Field(ge=0, le=100)
|
||||
weight: float = Field(default=1, gt=0, le=100)
|
||||
|
||||
|
||||
class RiskAssessmentRequest(ApiSchema):
|
||||
subject_id: str = Field(min_length=1, max_length=128)
|
||||
indicators: list[RiskIndicator] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class RiskFactorResult(ApiSchema):
|
||||
name: str
|
||||
value: float
|
||||
weight: float
|
||||
contribution: float
|
||||
|
||||
|
||||
class CategoryWarning(ApiSchema):
|
||||
category: str
|
||||
anomaly_count: int
|
||||
risk_level: str
|
||||
similar_category: str | None = None
|
||||
similar_count: int | None = None
|
||||
suggestion: str | None = None
|
||||
|
||||
|
||||
class SupplierFrequency(ApiSchema):
|
||||
supplier_name: str | None = None
|
||||
cooperation_count: int | None = None
|
||||
anomaly_count: int | None = None
|
||||
alpha: float = 1.0
|
||||
tier: str | None = None
|
||||
suggestion: str | None = None
|
||||
|
||||
|
||||
class StandardJudgment(ApiSchema):
|
||||
category: str
|
||||
risk_point: str
|
||||
rule: str
|
||||
source: str | None = None
|
||||
status: str = "待检测"
|
||||
|
||||
|
||||
class RiskDimensionScore(ApiSchema):
|
||||
dimension: str
|
||||
score: float
|
||||
weight: float
|
||||
|
||||
|
||||
class SimilarCase(ApiSchema):
|
||||
date: str | None = None
|
||||
product: str | None = None
|
||||
problem: str | None = None
|
||||
solution: str | None = None
|
||||
|
||||
|
||||
class RiskAssessmentResult(ApiSchema):
|
||||
subject_id: str
|
||||
score: float
|
||||
level: RiskLevel
|
||||
model_version: str
|
||||
factors: list[RiskFactorResult]
|
||||
risk_badge: str | None = None
|
||||
alert_text: str | None = None
|
||||
category_warning: CategoryWarning | None = None
|
||||
supplier_freq: SupplierFrequency | None = None
|
||||
standard_judgment: list[StandardJudgment] = Field(default_factory=list)
|
||||
dimension_scores: list[RiskDimensionScore] = Field(default_factory=list)
|
||||
similar_cases: list[SimilarCase] = Field(default_factory=list)
|
||||
ai_suggestions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModelInfo(ApiSchema):
|
||||
model_version: str
|
||||
score_range: str = "0-100"
|
||||
levels: tuple[RiskLevel, ...] = (
|
||||
RiskLevel.LOW,
|
||||
RiskLevel.MODERATELY_LOW,
|
||||
RiskLevel.MEDIUM,
|
||||
RiskLevel.HIGH,
|
||||
RiskLevel.VERY_HIGH,
|
||||
)
|
||||
59
app/schemas/standards.py
Normal file
59
app/schemas/standards.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Judgment standard library API contracts."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import ApiSchema
|
||||
|
||||
|
||||
class StandardCategoryCount(ApiSchema):
|
||||
category: str
|
||||
count: int
|
||||
|
||||
|
||||
class StandardRuleSummary(ApiSchema):
|
||||
id: int
|
||||
category_lv1: str
|
||||
category_lv2: str | None = None
|
||||
category_lv3: str | None = None
|
||||
risk_point: str
|
||||
judge_standard: str
|
||||
regulation: str | None = None
|
||||
status: str
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class StandardRuleDetail(StandardRuleSummary):
|
||||
attachment: str | None = None
|
||||
|
||||
|
||||
class StandardRuleListData(ApiSchema):
|
||||
items: list[StandardRuleSummary] = Field(default_factory=list)
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
category_counts: list[StandardCategoryCount] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StandardRuleCreate(ApiSchema):
|
||||
category_lv1: str = Field(min_length=1, max_length=20)
|
||||
category_lv2: str | None = Field(default=None, max_length=60)
|
||||
category_lv3: str | None = Field(default=None, max_length=60)
|
||||
risk_point: str = Field(min_length=1)
|
||||
judge_standard: str = Field(min_length=1)
|
||||
regulation: str | None = Field(default=None, max_length=300)
|
||||
attachment: str | None = Field(default=None, max_length=500)
|
||||
status: str = Field(default="启用", pattern="^(启用|停用)$")
|
||||
|
||||
|
||||
class StandardRuleUpdate(ApiSchema):
|
||||
category_lv1: str | None = Field(default=None, min_length=1, max_length=20)
|
||||
category_lv2: str | None = Field(default=None, max_length=60)
|
||||
category_lv3: str | None = Field(default=None, max_length=60)
|
||||
risk_point: str | None = Field(default=None, min_length=1)
|
||||
judge_standard: str | None = Field(default=None, min_length=1)
|
||||
regulation: str | None = Field(default=None, max_length=300)
|
||||
attachment: str | None = Field(default=None, max_length=500)
|
||||
status: str | None = Field(default=None, pattern="^(启用|停用)$")
|
||||
2
app/services/__init__.py
Normal file
2
app/services/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Service package."""
|
||||
|
||||
102
app/services/auth.py
Normal file
102
app/services/auth.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""Local account authentication and RBAC mapping."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.models.entities import OperationAudit, UserAccount
|
||||
from app.repositories.users import UserRepository
|
||||
from app.schemas.auth import TokenData, UserData
|
||||
|
||||
MAX_LOGIN_FAILURES = 5
|
||||
LOCK_MINUTES = 30
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, session) -> None: # noqa: ANN001
|
||||
self.repo = UserRepository(session)
|
||||
self.session = session
|
||||
|
||||
async def login(self, username: str, password: str) -> TokenData:
|
||||
user = await self.repo.get_by_username(username)
|
||||
now = datetime.now(UTC)
|
||||
if user is None or user.status != "启用":
|
||||
self.session.add(
|
||||
OperationAudit(
|
||||
actor_emp_no=username[:20],
|
||||
action="auth.login",
|
||||
result="failure",
|
||||
audit_metadata={"reason": "invalid_credentials"},
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
raise AppException("工号/企业邮箱或密码错误", code=401, status_code=401)
|
||||
if user.locked_until and user.locked_until > now:
|
||||
self.session.add(
|
||||
OperationAudit(
|
||||
actor_user_id=user.id,
|
||||
actor_emp_no=user.emp_no,
|
||||
action="auth.login",
|
||||
result="failure",
|
||||
audit_metadata={"reason": "account_locked"},
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
raise AppException("账号已锁定,请稍后重试", code=423, status_code=423)
|
||||
if not verify_password(password, user.password_hash):
|
||||
user.login_failed_count += 1
|
||||
if user.login_failed_count >= MAX_LOGIN_FAILURES:
|
||||
user.locked_until = now + timedelta(minutes=LOCK_MINUTES)
|
||||
user.login_failed_count = 0
|
||||
self.session.add(
|
||||
OperationAudit(
|
||||
actor_user_id=user.id,
|
||||
actor_emp_no=user.emp_no,
|
||||
action="auth.login",
|
||||
result="failure",
|
||||
audit_metadata={"reason": "invalid_credentials"},
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
await self.session.commit()
|
||||
raise AppException("工号/企业邮箱或密码错误", code=401, status_code=401)
|
||||
user.login_failed_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = now
|
||||
self.session.add(OperationAudit(actor_user_id=user.id, actor_emp_no=user.emp_no, action="auth.login"))
|
||||
await self.session.flush()
|
||||
return await self._token_data(user)
|
||||
|
||||
async def _token_data(self, user: UserAccount) -> TokenData:
|
||||
permissions = await self.repo.permissions_for_role(user.role)
|
||||
permission_keys = sorted({permission.func_key for permission in permissions})
|
||||
settings = get_settings()
|
||||
return TokenData(
|
||||
access_token=create_access_token(user.id, role=user.role),
|
||||
expires_in=settings.jwt_expire_minutes * 60,
|
||||
user=self.to_user_data(user, permission_keys),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_user_data(user: UserAccount, permissions: list[str] | None = None) -> UserData:
|
||||
return UserData(
|
||||
id=user.id,
|
||||
emp_no=user.emp_no,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
dept=user.dept,
|
||||
position=user.position,
|
||||
role=user.role,
|
||||
status=user.status,
|
||||
dingtalk_id=user.dingtalk_id,
|
||||
dingtalk_job_number=user.dingtalk_job_number,
|
||||
dingtalk_title=user.dingtalk_title,
|
||||
dingtalk_dept_id=user.dingtalk_dept_id,
|
||||
dingtalk_is_dept_manager=user.dingtalk_is_dept_manager,
|
||||
permissions=permissions or [],
|
||||
)
|
||||
|
||||
async def me(self, user: UserAccount) -> UserData:
|
||||
permissions = sorted({permission.func_key for permission in await self.repo.permissions_for_role(user.role)})
|
||||
return self.to_user_data(user, permissions)
|
||||
1008
app/services/conversations.py
Normal file
1008
app/services/conversations.py
Normal file
File diff suppressed because it is too large
Load Diff
389
app/services/dashboard.py
Normal file
389
app/services/dashboard.py
Normal file
@ -0,0 +1,389 @@
|
||||
"""Database-backed dashboard aggregation."""
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.entities import AnomalyCase, StandardRule
|
||||
from app.schemas.dashboard import (
|
||||
DashboardCategory,
|
||||
DashboardData,
|
||||
DashboardDefect,
|
||||
DashboardMatrixRow,
|
||||
DashboardMetric,
|
||||
DashboardStage,
|
||||
DashboardSupplierRank,
|
||||
DashboardTrendPoint,
|
||||
)
|
||||
|
||||
|
||||
class DashboardService:
|
||||
_stages = (
|
||||
("需求发现", "第一阶段"),
|
||||
("产品立项", "第二阶段"),
|
||||
("产品开发", "第三阶段"),
|
||||
("量产上市", "第四阶段"),
|
||||
("产品生命管理", "第五阶段"),
|
||||
)
|
||||
_category_order = (
|
||||
"面霜/护肤类",
|
||||
"洗发水/洗护类",
|
||||
"沐浴露类",
|
||||
"洁面/卸妆类",
|
||||
"身体护理类",
|
||||
"头发护理类",
|
||||
"脱毛类",
|
||||
"牙膏/口腔护理类",
|
||||
"彩妆/唇部类",
|
||||
"香氛/止汗类",
|
||||
"清洁剂类",
|
||||
"包材/物料专项",
|
||||
"待补标品类",
|
||||
)
|
||||
|
||||
def __init__(self, session) -> None: # noqa: ANN001
|
||||
self.session = session
|
||||
|
||||
async def overview(self, year: Literal["all", "2024", "2025", "2026"]) -> DashboardData:
|
||||
rows = list(
|
||||
(
|
||||
await self.session.execute(
|
||||
select(
|
||||
AnomalyCase.date,
|
||||
AnomalyCase.product_name,
|
||||
AnomalyCase.stage_new,
|
||||
AnomalyCase.anomaly_type,
|
||||
AnomalyCase.material_type,
|
||||
AnomalyCase.supplier,
|
||||
AnomalyCase.factory,
|
||||
AnomalyCase.defect_level,
|
||||
)
|
||||
)
|
||||
).mappings()
|
||||
)
|
||||
filtered = [row for row in rows if self._in_year(row["date"], year)]
|
||||
previous = self._previous_period(rows, year, filtered)
|
||||
total = len(filtered)
|
||||
previous_total = len(previous)
|
||||
standards_count = int(
|
||||
await self.session.scalar(
|
||||
select(func.count()).select_from(StandardRule).where(StandardRule.status == "启用")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
defects = self._defects(filtered, total)
|
||||
actors = self._actors(filtered)
|
||||
high_risk_count = sum(1 for item in actors.values() if item["tier"] in {"B", "C"})
|
||||
ab_count = sum(item.count for item in defects if item.level in {"A", "B"})
|
||||
metrics = [
|
||||
DashboardMetric(
|
||||
value=total,
|
||||
label="异常案例总数",
|
||||
change_percent=self._change(total, previous_total),
|
||||
),
|
||||
DashboardMetric(
|
||||
value=ab_count,
|
||||
label="A+B级缺陷",
|
||||
change_percent=self._change(
|
||||
ab_count,
|
||||
sum(1 for row in previous if row["defect_level"] in {"A", "B"}),
|
||||
),
|
||||
),
|
||||
DashboardMetric(
|
||||
value=high_risk_count,
|
||||
label="高风险供应商/工厂",
|
||||
change_percent=self._change(
|
||||
high_risk_count,
|
||||
sum(1 for item in self._actors(previous).values() if item["tier"] in {"B", "C"}),
|
||||
),
|
||||
),
|
||||
DashboardMetric(value=None, label="预测准确率", change_percent=None),
|
||||
DashboardMetric(value=standards_count, label="标准库覆盖", change_percent=None),
|
||||
]
|
||||
stages = self._stages_data(filtered, total)
|
||||
categories = self._categories_data(filtered, total)
|
||||
matrix_categories = [item.name for item in categories]
|
||||
matrix = self._matrix(filtered, matrix_categories)
|
||||
ranking = self._ranking(actors)
|
||||
trend = self._trend(filtered, year)
|
||||
insight = self._insight(trend, stages, categories)
|
||||
warnings = self._warnings(trend, categories, ranking)
|
||||
return DashboardData(
|
||||
year=year,
|
||||
total_cases=total,
|
||||
metrics=metrics,
|
||||
stages=stages,
|
||||
categories=categories,
|
||||
matrix_categories=matrix_categories,
|
||||
matrix=matrix,
|
||||
defects=defects,
|
||||
supplier_ranking=ranking,
|
||||
trend=trend,
|
||||
insight=insight,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _in_year(value: date | None, year: str) -> bool:
|
||||
return year == "all" or (value is not None and value.year == int(year))
|
||||
|
||||
@staticmethod
|
||||
def _change(current: int, previous: int) -> float | None:
|
||||
if not previous:
|
||||
return None
|
||||
return round((current - previous) / previous * 100, 1)
|
||||
|
||||
@staticmethod
|
||||
def _previous_period(rows, year: str, filtered) -> list: # noqa: ANN001
|
||||
if year != "all":
|
||||
return [
|
||||
row
|
||||
for row in rows
|
||||
if row["date"] is not None and row["date"].year == int(year) - 1
|
||||
]
|
||||
dates = [row["date"] for row in filtered if row["date"] is not None]
|
||||
if not dates:
|
||||
return []
|
||||
current_end = max(dates)
|
||||
current_start = current_end - timedelta(days=365)
|
||||
previous_start = current_start - timedelta(days=365)
|
||||
return [
|
||||
row
|
||||
for row in rows
|
||||
if row["date"] is not None
|
||||
and previous_start <= row["date"] < current_start
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _category(cls, product_name: str | None, material_type: str | None = None) -> str:
|
||||
value = (product_name or "").lower()
|
||||
if "脱毛" in value:
|
||||
return "脱毛类"
|
||||
if any(word in value for word in ("牙膏", "牙粉", "漱口", "口腔")):
|
||||
return "牙膏/口腔护理类"
|
||||
if any(word in value for word in ("洁面", "洁颜", "洗面", "卸妆")):
|
||||
return "洁面/卸妆类"
|
||||
if any(word in value for word in ("护发", "发膜", "护发素", "染发")):
|
||||
return "头发护理类"
|
||||
if any(word in value for word in ("唇", "口红", "唇膏", "气垫", "粉底", "彩妆", "腮红")):
|
||||
return "彩妆/唇部类"
|
||||
if any(word in value for word in ("香氛", "香水", "香体", "走珠", "狐臭", "止汗")):
|
||||
return "香氛/止汗类"
|
||||
if any(word in value for word in ("身体乳", "护手霜", "磨砂", "搓泥", "防晒")):
|
||||
return "身体护理类"
|
||||
if any(word in value for word in ("面霜", "乳液", "精华", "面膜", "素颜霜", "爽肤水", "化妆水", "眼霜")):
|
||||
return "面霜/护肤类"
|
||||
if any(word in value for word in ("洗发", "洗发水", "洗发露")):
|
||||
return "洗发水/洗护类"
|
||||
if "沐浴" in value:
|
||||
return "沐浴露类"
|
||||
if "牙膏" in value:
|
||||
return "牙膏类"
|
||||
if any(word in value for word in ("清洁剂", "消毒", "洁厕", "洗衣")):
|
||||
return "清洁剂类"
|
||||
if material_type and material_type.strip() not in {"--", "-"}:
|
||||
return "包材/物料专项"
|
||||
return "待补标品类"
|
||||
|
||||
@staticmethod
|
||||
def _risk_level_by_ratio(ratio: float) -> str:
|
||||
"""Use the four visual bands defined by the dashboard prototype."""
|
||||
|
||||
if ratio >= 0.8:
|
||||
return "高风险"
|
||||
if ratio >= 0.6:
|
||||
return "中高"
|
||||
if ratio >= 0.35:
|
||||
return "中等"
|
||||
return "较低"
|
||||
|
||||
@classmethod
|
||||
def _stages_data(cls, rows, total: int) -> list[DashboardStage]: # noqa: ANN001
|
||||
counts = Counter()
|
||||
for row in rows:
|
||||
raw = row["stage_new"] or ""
|
||||
for label, marker in cls._stages:
|
||||
if label in raw or marker in raw:
|
||||
counts[label] += 1
|
||||
break
|
||||
maximum = max(counts.values(), default=0)
|
||||
return [
|
||||
DashboardStage(
|
||||
name=label,
|
||||
count=counts[label],
|
||||
share=round(counts[label] / total * 100, 1) if total else 0,
|
||||
risk_level=cls._risk_level_by_ratio(counts[label] / maximum) if maximum else "较低",
|
||||
)
|
||||
for label, _ in cls._stages
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _categories_data(cls, rows, total: int) -> list[DashboardCategory]: # noqa: ANN001
|
||||
grouped: dict[str, list] = defaultdict(list)
|
||||
for row in rows:
|
||||
grouped[cls._category(row["product_name"], row["material_type"])].append(row)
|
||||
output = []
|
||||
for name in cls._category_order:
|
||||
group = grouped.get(name, [])
|
||||
if not group:
|
||||
continue
|
||||
materials = Counter(row["material_type"] for row in group if row["material_type"])
|
||||
output.append(
|
||||
DashboardCategory(
|
||||
name=name,
|
||||
count=len(group),
|
||||
share=round(len(group) / total * 100, 1) if total else 0,
|
||||
risk_level="较低",
|
||||
top_material=materials.most_common(1)[0][0] if materials else None,
|
||||
)
|
||||
)
|
||||
output = [item for item in output if item.count]
|
||||
maximum = max((item.count for item in output), default=0)
|
||||
return [
|
||||
item.model_copy(
|
||||
update={
|
||||
"risk_level": cls._risk_level_by_ratio(item.count / maximum)
|
||||
if maximum
|
||||
else "较低"
|
||||
}
|
||||
)
|
||||
for item in sorted(output, key=lambda item: (-item.count, cls._category_order.index(item.name)))
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _tier(count: int, has_a: bool = False) -> str:
|
||||
if count > 120 or has_a:
|
||||
return "C"
|
||||
if count > 60:
|
||||
return "B"
|
||||
if count >= 20:
|
||||
return "A"
|
||||
return "S"
|
||||
|
||||
@classmethod
|
||||
def _actors(cls, rows) -> dict[tuple[str, str], dict]: # noqa: ANN001
|
||||
grouped: dict[tuple[str, str], list] = defaultdict(list)
|
||||
for row in rows:
|
||||
for field, party_type in (("supplier", "供应商"), ("factory", "加工厂")):
|
||||
name = (row[field] or "").strip()
|
||||
if name and name != "--":
|
||||
grouped[(name, party_type)].append(row)
|
||||
return {
|
||||
key: {
|
||||
"count": len(items),
|
||||
"has_a": any(row["defect_level"] == "A" for row in items),
|
||||
"items": items,
|
||||
"tier": cls._tier(len(items), any(row["defect_level"] == "A" for row in items)),
|
||||
}
|
||||
for key, items in grouped.items()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _matrix(cls, rows, categories: list[str]) -> list[DashboardMatrixRow]: # noqa: ANN001
|
||||
actors = cls._actors(rows)
|
||||
result = []
|
||||
for (name, party_type), item in sorted(actors.items(), key=lambda entry: -entry[1]["count"]):
|
||||
anomaly_count = item["count"]
|
||||
# Keep the existing cooperation-total calculation unchanged.
|
||||
cooperation_total = item["count"]
|
||||
values = {category: 0 for category in categories}
|
||||
for row in item["items"]:
|
||||
category = cls._category(row["product_name"], row["material_type"])
|
||||
if category in values:
|
||||
values[category] += 1
|
||||
result.append(
|
||||
DashboardMatrixRow(
|
||||
name=name,
|
||||
party_type=party_type,
|
||||
categories=values,
|
||||
cooperation_total=cooperation_total,
|
||||
anomaly_count=anomaly_count,
|
||||
anomaly_rate=(
|
||||
round(anomaly_count / cooperation_total * 100, 1)
|
||||
if cooperation_total
|
||||
else 0
|
||||
),
|
||||
tier=item["tier"],
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _ranking(cls, actors: dict[tuple[str, str], dict]) -> list[DashboardSupplierRank]:
|
||||
ranking = []
|
||||
for (name, party_type), item in sorted(actors.items(), key=lambda entry: -entry[1]["count"]):
|
||||
problems = Counter(
|
||||
row["anomaly_type"] for row in item["items"] if row["anomaly_type"]
|
||||
)
|
||||
ranking.append(
|
||||
DashboardSupplierRank(
|
||||
name=name,
|
||||
party_type=party_type,
|
||||
count=item["count"],
|
||||
tier=item["tier"],
|
||||
top_problem=problems.most_common(1)[0][0] if problems else None,
|
||||
)
|
||||
)
|
||||
return ranking
|
||||
|
||||
@staticmethod
|
||||
def _defects(rows, total: int) -> list[DashboardDefect]: # noqa: ANN001
|
||||
labels = (("A", "A级缺陷"), ("B", "B级缺陷"), ("C", "C级缺陷"), ("D", "D级/未标注"))
|
||||
counts = Counter(row["defect_level"] if row["defect_level"] in {"A", "B", "C"} else "D" for row in rows)
|
||||
return [
|
||||
DashboardDefect(
|
||||
level=level,
|
||||
label=label,
|
||||
count=counts[level],
|
||||
share=round(counts[level] / total * 100, 1) if total else 0,
|
||||
)
|
||||
for level, label in labels
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _trend(cls, rows, year: str) -> list[DashboardTrendPoint]: # noqa: ANN001
|
||||
dated = [row["date"] for row in rows if row["date"] is not None]
|
||||
if not dated:
|
||||
return []
|
||||
end = max(dated)
|
||||
if year != "all":
|
||||
end = date(int(year), 12, 1)
|
||||
months = []
|
||||
current_year, current_month = end.year, end.month
|
||||
for _ in range(12):
|
||||
key = f"{current_year:04d}-{current_month:02d}"
|
||||
count = sum(1 for value in dated if value.year == current_year and value.month == current_month)
|
||||
months.append(DashboardTrendPoint(month=key, label=f"{current_month}月", count=count))
|
||||
current_month -= 1
|
||||
if current_month == 0:
|
||||
current_year -= 1
|
||||
current_month = 12
|
||||
return list(reversed(months))
|
||||
|
||||
@staticmethod
|
||||
def _insight(trend, stages, categories) -> str: # noqa: ANN001
|
||||
if len([item for item in trend if item.count]) < 3:
|
||||
return "数据量不足,暂无法形成稳定的趋势洞察。"
|
||||
recent = sum(item.count for item in trend[-3:])
|
||||
earlier = sum(item.count for item in trend[:3])
|
||||
direction = "上升" if recent > earlier * 1.1 else "下降" if recent < earlier * 0.9 else "基本平稳"
|
||||
stage = max(stages, key=lambda item: item.count, default=None)
|
||||
category = max(categories, key=lambda item: item.count, default=None)
|
||||
stage_name = stage.name if stage else "产品开发"
|
||||
category_name = category.name if category else "品类"
|
||||
return f"近期开案趋势{direction};{stage_name}阶段异常最多,{category_name}是当前主要风险来源。"
|
||||
|
||||
@staticmethod
|
||||
def _warnings(trend, categories, ranking) -> list[str]: # noqa: ANN001
|
||||
warnings = []
|
||||
if trend and trend[-1].count > 10:
|
||||
warnings.append(f"最近月份异常 {trend[-1].count} 条,已超过供应商月异常预警参考阈值。")
|
||||
for item in ranking:
|
||||
if item.count > 60:
|
||||
warnings.append(f"{item.name}{item.party_type}累计异常 {item.count} 条,层级为 {item.tier} 级。")
|
||||
for item in categories:
|
||||
if item.count > 50:
|
||||
warnings.append(f"{item.name}累计异常 {item.count} 条,建议重点关注{item.top_material or '高频物料'}。")
|
||||
return warnings[:5]
|
||||
190
app/services/dingtalk_auth.py
Normal file
190
app/services/dingtalk_auth.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""Server-side DingTalk auth-code exchange and local account upsert."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.security import create_access_token
|
||||
from app.models.entities import OperationAudit, UserAccount
|
||||
from app.repositories.users import UserRepository
|
||||
from app.schemas.auth import DingTalkConfigData, TokenData
|
||||
from app.services.auth import AuthService
|
||||
|
||||
TOKEN_URL = "https://oapi.dingtalk.com/gettoken"
|
||||
USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
|
||||
USERDETAIL_URL = "https://oapi.dingtalk.com/topapi/v2/user/get"
|
||||
|
||||
|
||||
class DingTalkServiceError(AppException):
|
||||
def __init__(self, message: str = "钉钉服务暂时不可用,请稍后重试") -> None:
|
||||
super().__init__(message, code=503, status_code=503)
|
||||
|
||||
|
||||
class DingTalkAuthService:
|
||||
_token_cache: tuple[str, float] | None = None
|
||||
|
||||
def __init__(self, session) -> None: # noqa: ANN001
|
||||
self.session = session
|
||||
self.repo = UserRepository(session)
|
||||
|
||||
@staticmethod
|
||||
def config() -> DingTalkConfigData:
|
||||
settings = get_settings()
|
||||
if not settings.dingtalk_app_key or not settings.dingtalk_agent_id or not settings.dingtalk_corp_id:
|
||||
raise DingTalkServiceError("钉钉免登未配置,请联系管理员")
|
||||
return DingTalkConfigData(
|
||||
corp_id=settings.dingtalk_corp_id,
|
||||
app_key=settings.dingtalk_app_key,
|
||||
agent_id=settings.dingtalk_agent_id,
|
||||
)
|
||||
|
||||
async def login(self, auth_code: str) -> TokenData:
|
||||
settings = get_settings()
|
||||
if not settings.dingtalk_app_secret or not settings.dingtalk_app_secret.get_secret_value().strip():
|
||||
raise DingTalkServiceError("钉钉免登未配置,请联系管理员")
|
||||
access_token = await self._app_access_token()
|
||||
user_id = await self._exchange_user_id(access_token, auth_code)
|
||||
detail = await self._user_detail(access_token, user_id)
|
||||
user = await self._upsert_user(user_id, detail)
|
||||
if user.status != "启用":
|
||||
raise AppException("用户账号已停用", code=401, status_code=401)
|
||||
permissions = await self.repo.permissions_for_role(user.role)
|
||||
permission_keys = sorted({item.func_key for item in permissions})
|
||||
self.session.add(
|
||||
OperationAudit(
|
||||
actor_user_id=user.id,
|
||||
actor_emp_no=user.emp_no,
|
||||
action="auth.dingtalk_login",
|
||||
)
|
||||
)
|
||||
return TokenData(
|
||||
access_token=create_access_token(user.id, role=user.role),
|
||||
expires_in=settings.jwt_expire_minutes * 60,
|
||||
user=AuthService.to_user_data(user, permission_keys),
|
||||
)
|
||||
|
||||
async def _app_access_token(self) -> str:
|
||||
settings = get_settings()
|
||||
if self._token_cache and self._token_cache[1] > time.time() + 60:
|
||||
return self._token_cache[0]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.get(
|
||||
TOKEN_URL,
|
||||
params={
|
||||
"appkey": settings.dingtalk_app_key,
|
||||
"appsecret": settings.dingtalk_app_secret.get_secret_value(),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise DingTalkServiceError() from exc
|
||||
if data.get("errcode") or not data.get("access_token"):
|
||||
raise DingTalkServiceError()
|
||||
self._token_cache = (data["access_token"], time.time() + int(data.get("expires_in", 7200)))
|
||||
return data["access_token"]
|
||||
|
||||
async def _exchange_user_id(self, access_token: str, auth_code: str) -> str:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.post(
|
||||
USERINFO_URL,
|
||||
params={"access_token": access_token},
|
||||
json={"code": auth_code},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) from exc
|
||||
user_id = (data.get("result") or {}).get("userid")
|
||||
if data.get("errcode") or not user_id:
|
||||
raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401)
|
||||
return str(user_id)
|
||||
|
||||
async def _user_detail(self, access_token: str, user_id: str) -> dict:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.post(
|
||||
USERDETAIL_URL,
|
||||
params={"access_token": access_token},
|
||||
json={"userid": user_id, "language": "zh_CN"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError:
|
||||
return {"userid": user_id}
|
||||
return data.get("result") or {"userid": user_id}
|
||||
|
||||
async def _upsert_user(self, user_id: str, detail: dict) -> UserAccount:
|
||||
user = await self.repo.get_by_dingtalk_id(user_id)
|
||||
role = self.map_role(
|
||||
str(detail.get("dept_name") or detail.get("deptName") or ""),
|
||||
str(detail.get("title") or detail.get("position") or ""),
|
||||
bool(detail.get("is_admin") or detail.get("is_dept_manager")),
|
||||
)
|
||||
if user is not None and role == "普通用户" and user.role != "普通用户":
|
||||
role = user.role
|
||||
if user is None:
|
||||
user = UserAccount(
|
||||
emp_no=f"dt_{user_id}"[:20],
|
||||
name=str(detail.get("name") or user_id)[:50],
|
||||
role=role,
|
||||
status="启用",
|
||||
dingtalk_id=user_id,
|
||||
)
|
||||
self.session.add(user)
|
||||
user.name = str(detail.get("name") or user.name)[:50]
|
||||
user.dept = str(detail.get("dept_name") or user.dept or "")[:50] or None
|
||||
user.position = str(detail.get("title") or user.position or "")[:50] or None
|
||||
user.role = role
|
||||
user.dingtalk_union_id = str(detail.get("unionid") or user.dingtalk_union_id or "")[:64] or None
|
||||
user.dingtalk_avatar = str(detail.get("avatar") or user.dingtalk_avatar or "")[:512] or None
|
||||
user.dingtalk_job_number = str(detail.get("job_number") or user.dingtalk_job_number or "")[:64] or None
|
||||
user.dingtalk_title = str(detail.get("title") or user.dingtalk_title or "")[:128] or None
|
||||
dept_id = detail.get("main_dept_id") or detail.get("dept_id") or user.dingtalk_dept_id
|
||||
if isinstance(dept_id, list):
|
||||
dept_id = dept_id[0] if dept_id else None
|
||||
try:
|
||||
user.dingtalk_dept_id = int(dept_id) if dept_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
user.dingtalk_dept_id = None
|
||||
user.dingtalk_is_dept_manager = bool(
|
||||
detail.get("is_admin")
|
||||
or detail.get("is_dept_manager")
|
||||
or user.dingtalk_is_dept_manager
|
||||
)
|
||||
role_codes = detail.get("role_list") or user.dingtalk_role_codes
|
||||
if isinstance(role_codes, (list, dict)):
|
||||
role_codes = json.dumps(role_codes, ensure_ascii=False)
|
||||
user.dingtalk_role_codes = str(role_codes or "")[:512] or None
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
user.dingtalk_id = user_id
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await self.session.flush()
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def map_role(dept: str, title: str, is_admin: bool = False) -> str:
|
||||
"""Map DingTalk organization snapshots to the PRD seven-role model."""
|
||||
|
||||
text = f"{dept} {title}".lower()
|
||||
if is_admin or "it" in text or "信息" in text or "系统" in text:
|
||||
return "admin"
|
||||
if "审核" in text or "review" in text:
|
||||
return "reviewer"
|
||||
if "采购" in text or "procurement" in text:
|
||||
return "procurement"
|
||||
if "品控" in text or "质量" in text or "qc" in text:
|
||||
return "qc"
|
||||
if "管理" in text or "主管" in text or "manager" in text:
|
||||
return "manager"
|
||||
if "运营" in text or "ops" in text:
|
||||
return "ops"
|
||||
if "开发" in text or "产品" in text or "dev" in text:
|
||||
return "dev"
|
||||
return "普通用户"
|
||||
163
app/services/llm_client.py
Normal file
163
app/services/llm_client.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""OpenAI-compatible LLM client for semantic extraction and streaming replies."""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import AppException
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Small provider-neutral client using the OpenAI chat-completions protocol."""
|
||||
|
||||
def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None:
|
||||
settings = get_settings()
|
||||
self.api_key = settings.llm_api_key.get_secret_value() if settings.llm_api_key else ""
|
||||
self.base_url = settings.llm_base_url.rstrip("/")
|
||||
self.model = settings.llm_model
|
||||
self.timeout = settings.llm_timeout_seconds
|
||||
self.temperature = settings.llm_temperature
|
||||
self.transport = transport
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
suffix = "/chat/completions"
|
||||
return f"{self.base_url}{suffix}" if self.base_url.endswith("/v1") else f"{self.base_url}/v1{suffix}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
if not self.configured:
|
||||
raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503)
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
|
||||
async def extract_semantics(
|
||||
self,
|
||||
text: str,
|
||||
history: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Extract structured product and intent fields before generating an answer."""
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是蝶雅产品风险预估系统的语义理解模块。"
|
||||
"只输出合法 JSON,不要输出 Markdown。抽取用户输入中的意图和风险评估参数。"
|
||||
"intent 只能是 greet、general、assessment、supplier、material、cases、"
|
||||
"standard、compliance、solution 之一。"
|
||||
"字段必须包含 intent, risk_requested, category, specification, packaging, "
|
||||
"supplier, factory, stage, confidence。"
|
||||
"只有用户明确要求风险评估、风险查询、标准判定、案例检索或预防方案时 risk_requested 才为 true;"
|
||||
"用户描述要开发新品并提供规格、容量、包装材质、供应商或加工厂等参数时,"
|
||||
"即使没有出现‘风险’二字,也必须将 intent 设为 assessment、risk_requested 设为 true;"
|
||||
"问候、闲聊、测试回复、要求原样回复等情况必须为 false。"
|
||||
"缺失字段使用 null,confidence 为 0 到 1 的数字。"
|
||||
),
|
||||
},
|
||||
]
|
||||
messages.extend(history or [])
|
||||
messages.append({"role": "user", "content": text})
|
||||
body = await self._request(messages, stream=False, json_mode=True)
|
||||
try:
|
||||
content = body["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc
|
||||
if isinstance(content, list):
|
||||
content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict))
|
||||
return self._parse_json(str(content))
|
||||
|
||||
async def stream_reply(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Yield text deltas from an OpenAI-compatible streaming response."""
|
||||
|
||||
headers = self._headers()
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": self.temperature,
|
||||
"stream": True,
|
||||
}
|
||||
timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout))
|
||||
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
|
||||
async with client.stream("POST", self.endpoint, headers=headers, json=payload) as response:
|
||||
if response.status_code >= 400:
|
||||
detail = await response.aread()
|
||||
raise AppException(
|
||||
f"LLM 请求失败(HTTP {response.status_code})",
|
||||
code=503,
|
||||
status_code=503,
|
||||
details=detail.decode("utf-8", errors="replace")[:500],
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[5:].strip()
|
||||
if not raw or raw == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = (choices[0].get("delta") or {}).get("content")
|
||||
if isinstance(delta, list):
|
||||
delta = "".join(
|
||||
str(part.get("text", "")) for part in delta if isinstance(part, dict)
|
||||
)
|
||||
if delta:
|
||||
yield str(delta)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
stream: bool,
|
||||
json_mode: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
headers = self._headers()
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": self.temperature,
|
||||
"stream": stream,
|
||||
}
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout))
|
||||
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
|
||||
try:
|
||||
response = await client.post(self.endpoint, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_json(content: str) -> dict[str, Any]:
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.removeprefix("```").removeprefix("json").removesuffix("```").strip()
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AppException("LLM 语义结果不是合法 JSON", code=503, status_code=503) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise AppException("LLM 语义结果格式无效", code=503, status_code=503)
|
||||
return parsed
|
||||
166
app/services/risk_items.py
Normal file
166
app/services/risk_items.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""Risk item submission, pre-review, and approval workflow."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import AppException
|
||||
from app.models.entities import AnomalyCase, OperationAudit, ReviewRecord, RiskItem, UserAccount
|
||||
from app.schemas.risk_item import ReviewRecordData, RiskItemCreate, RiskItemData, RiskItemUpdate
|
||||
|
||||
|
||||
class RiskItemService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def submit(self, payload: RiskItemCreate, user: UserAccount) -> RiskItemData:
|
||||
pre_result, pre_note = self._pre_review(payload)
|
||||
item = RiskItem(
|
||||
source_type=payload.source_type,
|
||||
source_file_id=payload.source_file_id,
|
||||
product_name=payload.product_name,
|
||||
product_category=payload.product_category,
|
||||
supplier=payload.supplier,
|
||||
factory=payload.factory,
|
||||
stage_new=payload.stage_new,
|
||||
description=payload.description,
|
||||
defect_level=payload.defect_level,
|
||||
solution=payload.solution,
|
||||
photos=payload.photos,
|
||||
submitted_by=user.emp_no,
|
||||
submitted_at=datetime.now(UTC),
|
||||
status="待审核" if pre_result == "通过" else "待预审",
|
||||
)
|
||||
self.session.add(item)
|
||||
await self.session.flush()
|
||||
self.session.add(
|
||||
ReviewRecord(
|
||||
risk_item_id=item.id,
|
||||
pre_review_result=pre_result,
|
||||
pre_review_note=pre_note,
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
OperationAudit(
|
||||
actor_user_id=user.id,
|
||||
actor_emp_no=user.emp_no,
|
||||
action="risk_item.submit",
|
||||
resource_type="risk_item",
|
||||
resource_id=str(item.id),
|
||||
audit_metadata={"source_type": payload.source_type, "pre_review": pre_result},
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return self.to_data(item)
|
||||
|
||||
async def list_pending(
|
||||
self,
|
||||
limit: int = 100,
|
||||
*,
|
||||
source_type: str = "all",
|
||||
status: str = "待审核",
|
||||
) -> list[RiskItemData]:
|
||||
filters = []
|
||||
if status != "all":
|
||||
filters.append(RiskItem.status == status)
|
||||
if source_type != "all":
|
||||
filters.append(RiskItem.source_type == source_type)
|
||||
statement = select(RiskItem).where(*filters).order_by(RiskItem.created_at.desc()).limit(limit)
|
||||
return [self.to_data(item) for item in await self.session.scalars(statement)]
|
||||
|
||||
async def update(self, item_id: int, payload: RiskItemUpdate, editor: UserAccount) -> RiskItemData:
|
||||
item = await self.session.get(RiskItem, item_id)
|
||||
if item is None:
|
||||
raise AppException("风险项不存在", code=404, status_code=404)
|
||||
if item.status not in {"待审核", "待预审"}:
|
||||
raise AppException("只有待处理风险项可以编辑", code=409, status_code=409)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "defect_level" in changes and changes["defect_level"] not in {None, "A", "B", "C"}:
|
||||
raise AppException("缺陷等级只能为 A、B 或 C", code=422, status_code=422)
|
||||
for key, value in changes.items():
|
||||
setattr(item, key, value)
|
||||
if item.status == "待预审" and len((item.description or "").strip()) >= 5:
|
||||
item.status = "待审核"
|
||||
record = await self.session.scalar(
|
||||
select(ReviewRecord).where(ReviewRecord.risk_item_id == item_id).order_by(ReviewRecord.id.desc())
|
||||
)
|
||||
if record:
|
||||
record.pre_review_result = "通过"
|
||||
record.pre_review_note = None
|
||||
self.session.add(
|
||||
OperationAudit(
|
||||
actor_user_id=editor.id,
|
||||
actor_emp_no=editor.emp_no,
|
||||
action="risk_item.edit",
|
||||
resource_type="risk_item",
|
||||
resource_id=str(item.id),
|
||||
audit_metadata={"fields": list(changes)},
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return self.to_data(item)
|
||||
|
||||
async def review(self, item_id: int, action: str, note: str, reviewer: UserAccount) -> ReviewRecordData:
|
||||
item = await self.session.get(RiskItem, item_id)
|
||||
if item is None:
|
||||
raise AppException("风险项不存在", code=404, status_code=404)
|
||||
if item.status != "待审核":
|
||||
raise AppException("该风险项当前不可审核", code=409, status_code=409)
|
||||
if item.submitted_by == reviewer.emp_no:
|
||||
raise AppException("审核人不能审核自己提交的风险项", code=403, status_code=403)
|
||||
|
||||
record = await self.session.scalar(
|
||||
select(ReviewRecord)
|
||||
.where(ReviewRecord.risk_item_id == item_id)
|
||||
.order_by(ReviewRecord.id.desc())
|
||||
)
|
||||
if record is None:
|
||||
raise AppException("审核记录不存在", code=409, status_code=409)
|
||||
record.reviewer = reviewer.emp_no
|
||||
record.review_action = action
|
||||
record.review_note = note
|
||||
record.reviewed_at = datetime.now(UTC)
|
||||
if action == "通过":
|
||||
case = AnomalyCase(
|
||||
product_name=item.product_name,
|
||||
product_category=item.product_category,
|
||||
supplier=item.supplier,
|
||||
factory=item.factory,
|
||||
stage_new=item.stage_new,
|
||||
description=item.description,
|
||||
defect_level=item.defect_level,
|
||||
solution=item.solution,
|
||||
source_type=item.source_type,
|
||||
import_fingerprint=f"risk-item-{item.id}",
|
||||
)
|
||||
self.session.add(case)
|
||||
await self.session.flush()
|
||||
record.case_id = case.id
|
||||
item.status = "已通过"
|
||||
else:
|
||||
item.status = "已驳回"
|
||||
self.session.add(
|
||||
OperationAudit(
|
||||
actor_user_id=reviewer.id,
|
||||
actor_emp_no=reviewer.emp_no,
|
||||
action="risk_item.review",
|
||||
resource_type="risk_item",
|
||||
resource_id=str(item.id),
|
||||
audit_metadata={"review_action": action},
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return ReviewRecordData.model_validate(record, from_attributes=True)
|
||||
|
||||
@staticmethod
|
||||
def _pre_review(payload: RiskItemCreate) -> tuple[str, str | None]:
|
||||
if len(payload.description.strip()) < 5:
|
||||
return "打回", "异常描述不能少于 5 个字符"
|
||||
if payload.defect_level and payload.defect_level not in {"A", "B", "C"}:
|
||||
return "打回", "缺陷等级只能为 A、B 或 C"
|
||||
return "通过", None
|
||||
|
||||
@staticmethod
|
||||
def to_data(item: RiskItem) -> RiskItemData:
|
||||
return RiskItemData.model_validate(item, from_attributes=True)
|
||||
42
app/services/risk_prediction.py
Normal file
42
app/services/risk_prediction.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Risk prediction use-case orchestration."""
|
||||
|
||||
from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue
|
||||
from app.schemas.risk_prediction import (
|
||||
ModelInfo,
|
||||
RiskAssessmentRequest,
|
||||
RiskAssessmentResult,
|
||||
RiskFactorResult,
|
||||
)
|
||||
|
||||
|
||||
class RiskPredictionService:
|
||||
_engine = BaselineRiskEngine()
|
||||
|
||||
@classmethod
|
||||
def model_info(cls) -> ModelInfo:
|
||||
return ModelInfo(model_version=cls._engine.model_version)
|
||||
|
||||
@classmethod
|
||||
def preview(cls, request: RiskAssessmentRequest) -> RiskAssessmentResult:
|
||||
score = cls._engine.predict(
|
||||
[
|
||||
IndicatorValue(name=item.name, value=item.value, weight=item.weight)
|
||||
for item in request.indicators
|
||||
]
|
||||
)
|
||||
return RiskAssessmentResult(
|
||||
subject_id=request.subject_id,
|
||||
score=score.score,
|
||||
level=score.level,
|
||||
model_version=cls._engine.model_version,
|
||||
factors=[
|
||||
RiskFactorResult(
|
||||
name=item.name,
|
||||
value=item.value,
|
||||
weight=item.weight,
|
||||
contribution=item.contribution,
|
||||
)
|
||||
for item in score.factors
|
||||
],
|
||||
)
|
||||
|
||||
149
app/services/standards.py
Normal file
149
app/services/standards.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""Standard library queries and administrator maintenance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import AppException
|
||||
from app.models.entities import OperationAudit, StandardRule, UserAccount
|
||||
from app.schemas.standards import (
|
||||
StandardCategoryCount,
|
||||
StandardRuleCreate,
|
||||
StandardRuleDetail,
|
||||
StandardRuleListData,
|
||||
StandardRuleSummary,
|
||||
StandardRuleUpdate,
|
||||
)
|
||||
|
||||
|
||||
class StandardLibraryService:
|
||||
categories = ("内料", "包材", "包装", "法规")
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
category: str = "all",
|
||||
status: str = "启用",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> StandardRuleListData:
|
||||
filters = self._filters(keyword=keyword, category=category, status=status)
|
||||
total = int(await self.session.scalar(select(func.count()).select_from(StandardRule).where(*filters)) or 0)
|
||||
statement = (
|
||||
select(StandardRule)
|
||||
.where(*filters)
|
||||
.order_by(StandardRule.category_lv1, StandardRule.id)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
rows = list(await self.session.scalars(statement))
|
||||
category_counts = await self._category_counts(keyword=keyword, status=status)
|
||||
return StandardRuleListData(
|
||||
items=[self.to_summary(row) for row in rows],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
category_counts=category_counts,
|
||||
)
|
||||
|
||||
async def get(self, rule_id: int) -> StandardRuleDetail:
|
||||
row = await self.session.get(StandardRule, rule_id)
|
||||
if row is None:
|
||||
raise AppException("标准条目不存在", code=404, status_code=404)
|
||||
return self.to_detail(row)
|
||||
|
||||
async def create(self, payload: StandardRuleCreate, user: UserAccount) -> StandardRuleDetail:
|
||||
row = StandardRule(
|
||||
**payload.model_dump(),
|
||||
import_fingerprint=self._manual_fingerprint(payload),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
await self._audit(user, "standard.create", row.id)
|
||||
return self.to_detail(row)
|
||||
|
||||
async def update(self, rule_id: int, payload: StandardRuleUpdate, user: UserAccount) -> StandardRuleDetail:
|
||||
row = await self.session.get(StandardRule, rule_id)
|
||||
if row is None:
|
||||
raise AppException("标准条目不存在", code=404, status_code=404)
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await self._audit(user, "standard.update", row.id, {"fields": list(payload.model_fields_set)})
|
||||
await self.session.flush()
|
||||
return self.to_detail(row)
|
||||
|
||||
async def _category_counts(self, *, keyword: str | None, status: str) -> list[StandardCategoryCount]:
|
||||
filters = self._filters(keyword=keyword, category="all", status=status)
|
||||
rows = await self.session.execute(
|
||||
select(StandardRule.category_lv1, func.count())
|
||||
.where(*filters)
|
||||
.group_by(StandardRule.category_lv1)
|
||||
)
|
||||
counts = dict(rows.all())
|
||||
return [
|
||||
StandardCategoryCount(category=category, count=int(counts.get(category, 0)))
|
||||
for category in self.categories
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _filters(*, keyword: str | None, category: str, status: str) -> list[Any]:
|
||||
filters: list[Any] = []
|
||||
if category != "all":
|
||||
if category not in StandardLibraryService.categories:
|
||||
raise AppException("无效的标准类目", code=422, status_code=422)
|
||||
filters.append(StandardRule.category_lv1 == category)
|
||||
if status != "all":
|
||||
if status not in {"启用", "停用"}:
|
||||
raise AppException("无效的标准状态", code=422, status_code=422)
|
||||
filters.append(StandardRule.status == status)
|
||||
if keyword and keyword.strip():
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
StandardRule.category_lv1.ilike(pattern),
|
||||
StandardRule.category_lv2.ilike(pattern),
|
||||
StandardRule.category_lv3.ilike(pattern),
|
||||
StandardRule.risk_point.ilike(pattern),
|
||||
StandardRule.judge_standard.ilike(pattern),
|
||||
StandardRule.regulation.ilike(pattern),
|
||||
)
|
||||
)
|
||||
return filters
|
||||
|
||||
async def _audit(self, user: UserAccount, action: str, rule_id: int, metadata: dict | None = None) -> None:
|
||||
self.session.add(
|
||||
OperationAudit(
|
||||
actor_user_id=user.id,
|
||||
actor_emp_no=user.emp_no,
|
||||
action=action,
|
||||
resource_type="standard_rule",
|
||||
resource_id=str(rule_id),
|
||||
audit_metadata=metadata or {},
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _manual_fingerprint(payload: StandardRuleCreate) -> str:
|
||||
content = "\x1f".join(str(value or "") for value in payload.model_dump().values())
|
||||
return hashlib.sha256(f"manual\x1f{content}".encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def to_summary(row: StandardRule) -> StandardRuleSummary:
|
||||
return StandardRuleSummary.model_validate(row, from_attributes=True)
|
||||
|
||||
@staticmethod
|
||||
def to_detail(row: StandardRule) -> StandardRuleDetail:
|
||||
return StandardRuleDetail.model_validate(row, from_attributes=True)
|
||||
42
docs/architecture.md
Normal file
42
docs/architecture.md
Normal file
@ -0,0 +1,42 @@
|
||||
# 后端架构
|
||||
|
||||
后端采用模块化分层单体。HTTP、业务编排、领域计算和持久化彼此隔离,后续可以替换风险模型而不改 API 层。
|
||||
|
||||
```text
|
||||
HTTP Request
|
||||
-> app/api/v1 路由、参数接收、响应
|
||||
-> app/services 用例编排
|
||||
-> app/domain 纯领域规则/模型适配器
|
||||
-> app/repositories 数据访问
|
||||
-> app/models SQLAlchemy ORM
|
||||
-> PostgreSQL
|
||||
```
|
||||
|
||||
- `app/core`:配置、日志和统一异常。
|
||||
- `app/database`:异步 Engine、Session 和事务边界。
|
||||
- `app/schemas`:Pydantic 请求/响应契约,外部 JSON 使用 camelCase。
|
||||
- `app/domain/risk_engine.py`:当前为可替换的基线评分引擎;未来可接 sklearn、XGBoost 或远程模型服务。
|
||||
- 风险评分遵循 PRD:输入值表示风险程度,先按 `S = 100 - X` 转为安全得分,再按权重求综合分;`R >= 80` 低风险,`60 <= R < 80` 较低风险,`40 <= R < 60` 中等风险,`20 <= R < 40` 高风险,`R < 20` 极高风险。
|
||||
- `migrations`:Alembic 数据库迁移。
|
||||
- `tests`:API 和领域服务测试。
|
||||
|
||||
认证与审核链路:
|
||||
|
||||
```text
|
||||
密码登录 / 钉钉 authCode
|
||||
-> UserAccount + 角色映射
|
||||
-> JWT
|
||||
-> 每次请求从 JWT 查用户并读取 role_permissions
|
||||
-> 风险项自动预审
|
||||
-> 待审核队列
|
||||
-> 审核通过写入 anomaly_cases / 驳回退回
|
||||
-> operation_audits 追加式留痕
|
||||
```
|
||||
|
||||
## PRD 核心数据表
|
||||
|
||||
首期数据库包含 `anomaly_cases`、`supplier_profiles`、`risk_assessments`、`risk_items`、
|
||||
`review_records`、`standard_rules`、`user_accounts` 和 `role_permissions` 八张核心表。
|
||||
|
||||
目标 PostgreSQL 当前未安装 `pgvector`,因此 `anomaly_cases.embedding` 暂用可空 `JSONB`
|
||||
保存浮点数组。安装 `vector` 扩展后,应通过独立迁移将该字段改为 `VECTOR(768)` 并补充向量索引。
|
||||
60
migrations/env.py
Normal file
60
migrations/env.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Alembic asynchronous PostgreSQL migration environment."""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from app import models # noqa: F401
|
||||
from app.core.config import get_settings
|
||||
from app.database.base import Base
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
config = context.config
|
||||
settings = get_settings()
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%"))
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
||||
26
migrations/script.py.mako
Normal file
26
migrations/script.py.mako
Normal file
@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
192
migrations/versions/20260828_0001_prd_core_schema.py
Normal file
192
migrations/versions/20260828_0001_prd_core_schema.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""Create the PRD core database schema.
|
||||
|
||||
Revision ID: 20260828_0001
|
||||
Revises:
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260828_0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"standard_rules",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("category_lv1", sa.String(length=20), nullable=False),
|
||||
sa.Column("category_lv2", sa.String(length=60), nullable=True),
|
||||
sa.Column("category_lv3", sa.String(length=60), nullable=True),
|
||||
sa.Column("risk_point", sa.Text(), nullable=False),
|
||||
sa.Column("judge_standard", sa.Text(), nullable=False),
|
||||
sa.Column("regulation", sa.String(length=300), nullable=True),
|
||||
sa.Column("attachment", sa.String(length=200), nullable=True),
|
||||
sa.Column("status", sa.String(length=10), server_default="启用", nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_standard_rules_categories", "standard_rules", ["category_lv1", "category_lv2", "category_lv3"])
|
||||
op.create_index("ix_standard_rules_status", "standard_rules", ["status"])
|
||||
|
||||
op.create_table(
|
||||
"anomaly_cases",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=True),
|
||||
sa.Column("product_code", sa.String(length=50), nullable=True),
|
||||
sa.Column("product_name", sa.String(length=200), nullable=True),
|
||||
sa.Column("product_category", sa.String(length=50), nullable=True),
|
||||
sa.Column("stage_new", sa.String(length=50), nullable=True),
|
||||
sa.Column("stage_old", sa.String(length=50), nullable=True),
|
||||
sa.Column("anomaly_type", sa.String(length=50), nullable=True),
|
||||
sa.Column("material_type", sa.String(length=50), nullable=True),
|
||||
sa.Column("supplier", sa.String(length=100), nullable=True),
|
||||
sa.Column("factory", sa.String(length=100), nullable=True),
|
||||
sa.Column("person", sa.String(length=50), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("root_cause", sa.Text(), nullable=True),
|
||||
sa.Column("solution", sa.Text(), nullable=True),
|
||||
sa.Column("result", sa.String(length=50), nullable=True),
|
||||
sa.Column("responsible", sa.String(length=100), nullable=True),
|
||||
sa.Column("defect_level", sa.String(length=5), nullable=True),
|
||||
sa.Column("is_resolved", sa.String(length=10), nullable=True),
|
||||
sa.Column("source_file", sa.String(length=200), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=20), nullable=True),
|
||||
sa.Column("standard_rule_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("embedding", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["standard_rule_id"], ["standard_rules.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for column in ("date", "product_category", "supplier", "factory", "material_type", "defect_level"):
|
||||
op.create_index(f"ix_anomaly_cases_{column}", "anomaly_cases", [column])
|
||||
|
||||
op.create_table(
|
||||
"supplier_profiles",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("supplier_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("total_anomalies", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("frequency_coef", sa.Numeric(precision=4, scale=2), server_default="1.00", nullable=True),
|
||||
sa.Column("tier", sa.String(length=1), nullable=True),
|
||||
sa.Column("category_breakdown", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False),
|
||||
sa.Column(
|
||||
"product_cat_breakdown",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default="{}",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("stage_breakdown", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False),
|
||||
sa.Column("monthly_trend", postgresql.JSONB(astext_type=sa.Text()), server_default="[]", nullable=False),
|
||||
sa.Column("risk_score", sa.Integer(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("supplier_name"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"risk_assessments",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.String(length=50), nullable=True),
|
||||
sa.Column("product_name", sa.String(length=200), nullable=True),
|
||||
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False),
|
||||
sa.Column("risk_score", sa.Integer(), nullable=True),
|
||||
sa.Column("risk_level", sa.String(length=10), nullable=True),
|
||||
sa.Column("dimension_scores", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False),
|
||||
sa.Column("similar_cases", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False),
|
||||
sa.Column("ai_suggestions", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_risk_assessments_user_id", "risk_assessments", ["user_id"])
|
||||
op.create_index("ix_risk_assessments_created_at", "risk_assessments", ["created_at"])
|
||||
|
||||
op.create_table(
|
||||
"risk_items",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("source_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("source_file_id", sa.String(length=50), nullable=True),
|
||||
sa.Column("product_name", sa.String(length=200), nullable=True),
|
||||
sa.Column("product_category", sa.String(length=50), nullable=True),
|
||||
sa.Column("supplier", sa.String(length=100), nullable=True),
|
||||
sa.Column("factory", sa.String(length=100), nullable=True),
|
||||
sa.Column("stage_new", sa.String(length=50), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("defect_level", sa.String(length=5), nullable=True),
|
||||
sa.Column("solution", sa.Text(), nullable=True),
|
||||
sa.Column("photos", postgresql.JSONB(astext_type=sa.Text()), server_default="[]", nullable=False),
|
||||
sa.Column("submitted_by", sa.String(length=50), nullable=True),
|
||||
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), server_default="待预审", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_risk_items_status", "risk_items", ["status"])
|
||||
op.create_index("ix_risk_items_submitted_by", "risk_items", ["submitted_by"])
|
||||
op.create_index("ix_risk_items_submitted_at", "risk_items", ["submitted_at"])
|
||||
|
||||
op.create_table(
|
||||
"user_accounts",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("emp_no", sa.String(length=20), nullable=False),
|
||||
sa.Column("name", sa.String(length=50), nullable=False),
|
||||
sa.Column("dept", sa.String(length=50), nullable=True),
|
||||
sa.Column("position", sa.String(length=50), nullable=True),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("status", sa.String(length=10), server_default="启用", nullable=False),
|
||||
sa.Column("dingtalk_id", sa.String(length=50), nullable=True),
|
||||
sa.Column("extra_perms", postgresql.JSONB(astext_type=sa.Text()), server_default="[]", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("dingtalk_id"),
|
||||
sa.UniqueConstraint("emp_no"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"role_permissions",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("func_key", sa.String(length=50), nullable=False),
|
||||
sa.Column("data_level", sa.String(length=10), server_default="L1", nullable=False),
|
||||
sa.Column("can_view", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("can_operate", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_role_permissions_role", "role_permissions", ["role"])
|
||||
op.create_index("uq_role_permissions_role_func", "role_permissions", ["role", "func_key"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"review_records",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("risk_item_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("pre_review_result", sa.String(length=20), nullable=True),
|
||||
sa.Column("pre_review_note", sa.Text(), nullable=True),
|
||||
sa.Column("reviewer", sa.String(length=50), nullable=True),
|
||||
sa.Column("review_action", sa.String(length=20), nullable=True),
|
||||
sa.Column("review_note", sa.Text(), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("case_id", sa.BigInteger(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["case_id"], ["anomaly_cases.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["risk_item_id"], ["risk_items.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_review_records_risk_item_id", "review_records", ["risk_item_id"])
|
||||
op.create_index("ix_review_records_reviewer", "review_records", ["reviewer"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("review_records")
|
||||
op.drop_table("role_permissions")
|
||||
op.drop_table("user_accounts")
|
||||
op.drop_table("risk_items")
|
||||
op.drop_table("risk_assessments")
|
||||
op.drop_table("supplier_profiles")
|
||||
op.drop_table("anomaly_cases")
|
||||
op.drop_table("standard_rules")
|
||||
164
migrations/versions/20260828_0002_add_prd_comments.py
Normal file
164
migrations/versions/20260828_0002_add_prd_comments.py
Normal file
@ -0,0 +1,164 @@
|
||||
"""Add PRD descriptions to tables and columns.
|
||||
|
||||
Revision ID: 20260828_0002
|
||||
Revises: 20260828_0001
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260828_0002"
|
||||
down_revision: str | None = "20260828_0001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
COMMENTS = {
|
||||
"standard_rules": {
|
||||
"__table__": "判定标准库,保存内料、包材、包装和法规等四级风险判定标准。",
|
||||
"id": "判定标准主键,自增编号。",
|
||||
"category_lv1": "一级类目:内料、包材、包装或法规。",
|
||||
"category_lv2": "二级类目:风险域。",
|
||||
"category_lv3": "三级类目:风险子类。",
|
||||
"risk_point": "四级类目:具体风险点。",
|
||||
"judge_standard": "判定标准与依据,包括量化阈值和检测方法。",
|
||||
"regulation": "法规或数据来源。",
|
||||
"attachment": "标准原文或内部验收标准附件。",
|
||||
"status": "标准状态:启用或停用。",
|
||||
"updated_at": "标准最后更新时间。",
|
||||
},
|
||||
"anomaly_cases": {
|
||||
"__table__": "历史异常案例库,审核通过后供经验引擎检索和风险预测使用。",
|
||||
"id": "异常案例主键,自增编号。",
|
||||
"date": "异常发生日期。",
|
||||
"product_code": "产品编码。",
|
||||
"product_name": "产品名称。",
|
||||
"product_category": "产品品类,例如洗发水、面霜等。",
|
||||
"stage_new": "新版流程阶段。",
|
||||
"stage_old": "旧版流程阶段。",
|
||||
"anomaly_type": "异常类别。",
|
||||
"material_type": "物料属性或物料类别。",
|
||||
"supplier": "供应商名称。",
|
||||
"factory": "加工厂名称。",
|
||||
"person": "项目负责人。",
|
||||
"description": "异常问题描述。",
|
||||
"root_cause": "异常原因分析。",
|
||||
"solution": "解决方案。",
|
||||
"result": "处理结果。",
|
||||
"responsible": "异常责任方。",
|
||||
"defect_level": "缺陷等级:A、B 或 C。",
|
||||
"is_resolved": "是否解决。",
|
||||
"source_file": "数据来源文件。",
|
||||
"source_type": "数据来源:导入、表单、语音或 OCR。",
|
||||
"standard_rule_id": "关联的判定标准编号,可为空。",
|
||||
"embedding": "语义向量;当前服务器未安装 pgvector,暂以 JSONB 浮点数组保存。",
|
||||
"created_at": "案例入库时间。",
|
||||
},
|
||||
"supplier_profiles": {
|
||||
"__table__": "供应商风险画像及其统计汇总。",
|
||||
"id": "供应商画像主键,自增编号。",
|
||||
"supplier_name": "供应商名称,必须唯一。",
|
||||
"total_anomalies": "该供应商的异常记录总数。",
|
||||
"frequency_coef": "频次风险系数 alpha,按近三年异常次数计算并封顶 1.5。",
|
||||
"tier": "供应商层级:S、A、B 或 C。",
|
||||
"category_breakdown": "按物料类别统计的异常分布,例如 {彩盒: 60, 标贴: 30}。",
|
||||
"product_cat_breakdown": "按产品品类统计的异常分布。",
|
||||
"stage_breakdown": "按流程阶段统计的异常分布。",
|
||||
"monthly_trend": "按月份统计的异常趋势。",
|
||||
"risk_score": "供应商风险评分,范围 0-100。",
|
||||
"updated_at": "供应商画像最后更新时间。",
|
||||
},
|
||||
"risk_assessments": {
|
||||
"__table__": "用户发起的风险评估记录及结构化结果。",
|
||||
"id": "风险评估主键,自增编号。",
|
||||
"user_id": "发起评估的用户标识。",
|
||||
"product_name": "参与评估的产品名称。",
|
||||
"params": "风险评估的原始输入参数。",
|
||||
"risk_score": "综合风险评分,范围 0-100。",
|
||||
"risk_level": "风险等级。",
|
||||
"dimension_scores": "各风险维度评分,包含品类维度。",
|
||||
"similar_cases": "相似案例及相似度信息,例如案例编号和相似度列表。",
|
||||
"ai_suggestions": "AI 生成的风险预防建议。",
|
||||
"created_at": "风险评估创建时间。",
|
||||
},
|
||||
"risk_items": {
|
||||
"__table__": "风险项录入暂存表,提交后进入自动预审和人工审核流程。",
|
||||
"id": "风险项主键,自增编号。",
|
||||
"source_type": "录入来源:表单、语音、OCR 或 Excel 导入。",
|
||||
"source_file_id": "OCR 原始图片或报告附件标识。",
|
||||
"product_name": "产品名称。",
|
||||
"product_category": "产品品类。",
|
||||
"supplier": "供应商名称。",
|
||||
"factory": "加工厂名称。",
|
||||
"stage_new": "新版流程阶段。",
|
||||
"description": "风险或异常描述。",
|
||||
"defect_level": "缺陷等级:A、B 或 C。",
|
||||
"solution": "解决方案。",
|
||||
"photos": "异常照片附件标识列表。",
|
||||
"submitted_by": "提交人标识。",
|
||||
"submitted_at": "提交时间。",
|
||||
"status": "审核状态:待预审、待审核、已通过或已驳回。",
|
||||
"created_at": "风险项创建时间。",
|
||||
},
|
||||
"review_records": {
|
||||
"__table__": "风险项审核记录,保留自动预审和人工审核全过程。",
|
||||
"id": "审核记录主键,自增编号。",
|
||||
"risk_item_id": "关联的风险项编号。",
|
||||
"pre_review_result": "自动预审结果:通过或打回。",
|
||||
"pre_review_note": "自动预审意见。",
|
||||
"reviewer": "人工审核人标识。",
|
||||
"review_action": "人工审核动作:通过或驳回。",
|
||||
"review_note": "人工审核意见。",
|
||||
"reviewed_at": "人工审核时间。",
|
||||
"case_id": "审核通过后关联生成的异常案例编号,可为空。",
|
||||
},
|
||||
"user_accounts": {
|
||||
"__table__": "企业用户账号及其 RBAC 角色信息。",
|
||||
"id": "用户账号主键,自增编号。",
|
||||
"emp_no": "员工工号,必须唯一。",
|
||||
"name": "用户姓名。",
|
||||
"dept": "所属部门。",
|
||||
"position": "岗位。",
|
||||
"role": "角色:dev、procurement、qc、ops、reviewer、manager 或 admin。",
|
||||
"status": "账号状态:启用或停用。",
|
||||
"dingtalk_id": "钉钉账号标识,必须唯一,可为空。",
|
||||
"extra_perms": "临时权限申请列表,包含功能标识和有效期。",
|
||||
"created_at": "账号创建时间。",
|
||||
"updated_at": "账号最后更新时间。",
|
||||
},
|
||||
"role_permissions": {
|
||||
"__table__": "角色权限矩阵,控制功能权限和数据分级访问。",
|
||||
"id": "角色权限主键,自增编号。",
|
||||
"role": "角色名称。",
|
||||
"func_key": "功能标识,包括菜单、按钮或接口。",
|
||||
"data_level": "数据访问级别:L1、L2 或 L3。",
|
||||
"can_view": "是否允许查看。",
|
||||
"can_operate": "是否允许操作,例如录入、审核或配置。",
|
||||
"updated_at": "权限最后更新时间。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table_name, table_comments in COMMENTS.items():
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{_escape(table_comments['__table__'])}'")
|
||||
for column_name, comment in table_comments.items():
|
||||
if column_name != "__table__":
|
||||
op.execute(
|
||||
f"COMMENT ON COLUMN {table_name}.{column_name} IS '{_escape(comment)}'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table_name, table_comments in COMMENTS.items():
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS NULL")
|
||||
for column_name in table_comments:
|
||||
if column_name != "__table__":
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS NULL")
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
"""Preserve source-only fields and make Excel imports idempotent.
|
||||
|
||||
Revision ID: 20260828_0003
|
||||
Revises: 20260828_0002
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260828_0003"
|
||||
down_revision: str | None = "20260828_0002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("anomaly_cases", sa.Column("brand", sa.String(length=100), nullable=True))
|
||||
op.add_column("anomaly_cases", sa.Column("specification", sa.String(length=100), nullable=True))
|
||||
op.add_column("anomaly_cases", sa.Column("remark", sa.Text(), nullable=True))
|
||||
op.add_column("anomaly_cases", sa.Column("import_fingerprint", sa.String(length=64), nullable=True))
|
||||
op.create_unique_constraint("uq_anomaly_cases_import_fingerprint", "anomaly_cases", ["import_fingerprint"])
|
||||
|
||||
op.add_column("standard_rules", sa.Column("import_fingerprint", sa.String(length=64), nullable=True))
|
||||
op.create_unique_constraint("uq_standard_rules_import_fingerprint", "standard_rules", ["import_fingerprint"])
|
||||
|
||||
op.execute("COMMENT ON COLUMN anomaly_cases.brand IS '源 Excel 中的品牌字段。'")
|
||||
op.execute("COMMENT ON COLUMN anomaly_cases.specification IS '源 Excel 中的产品规格字段。'")
|
||||
op.execute("COMMENT ON COLUMN anomaly_cases.remark IS '源 Excel 中的备注字段。'")
|
||||
op.execute("COMMENT ON COLUMN anomaly_cases.import_fingerprint IS '源文件、工作表和行号生成的导入幂等指纹。'")
|
||||
op.execute("COMMENT ON COLUMN standard_rules.import_fingerprint IS '源文件、工作表和行号生成的导入幂等指纹。'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("uq_standard_rules_import_fingerprint", "standard_rules", type_="unique")
|
||||
op.drop_column("standard_rules", "import_fingerprint")
|
||||
op.drop_constraint("uq_anomaly_cases_import_fingerprint", "anomaly_cases", type_="unique")
|
||||
op.drop_column("anomaly_cases", "import_fingerprint")
|
||||
op.drop_column("anomaly_cases", "remark")
|
||||
op.drop_column("anomaly_cases", "specification")
|
||||
op.drop_column("anomaly_cases", "brand")
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
"""Widen imported anomaly columns to preserve source workbook text.
|
||||
|
||||
Revision ID: 20260828_0004
|
||||
Revises: 20260828_0003
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260828_0004"
|
||||
down_revision: str | None = "20260828_0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for column_name, column_type in (
|
||||
("product_code", sa.String(length=100)),
|
||||
("product_name", sa.String(length=500)),
|
||||
("product_category", sa.String(length=100)),
|
||||
("stage_new", sa.String(length=100)),
|
||||
("stage_old", sa.String(length=100)),
|
||||
("anomaly_type", sa.String(length=200)),
|
||||
("material_type", sa.String(length=200)),
|
||||
("supplier", sa.String(length=300)),
|
||||
("factory", sa.String(length=300)),
|
||||
("person", sa.String(length=200)),
|
||||
("result", sa.Text()),
|
||||
("responsible", sa.String(length=300)),
|
||||
("defect_level", sa.String(length=10)),
|
||||
("is_resolved", sa.Text()),
|
||||
("source_file", sa.String(length=300)),
|
||||
("source_type", sa.String(length=30)),
|
||||
):
|
||||
op.alter_column("anomaly_cases", column_name, existing_type=sa.String(), type_=column_type)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Narrowing could truncate imported source text, so this is intentionally irreversible.
|
||||
raise NotImplementedError("The widened import columns must not be narrowed after data import.")
|
||||
@ -0,0 +1,31 @@
|
||||
"""Widen standard attachment links for the supplied workbook.
|
||||
|
||||
Revision ID: 20260828_0005
|
||||
Revises: 20260828_0004
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260828_0005"
|
||||
down_revision: str | None = "20260828_0004"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"standard_rules",
|
||||
"attachment",
|
||||
existing_type=sa.String(length=200),
|
||||
type_=sa.String(length=500),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Narrowing could truncate imported attachment links.
|
||||
raise NotImplementedError("The standard attachment column must not be narrowed after import.")
|
||||
|
||||
32
migrations/versions/20260828_0006_add_auth_fields.py
Normal file
32
migrations/versions/20260828_0006_add_auth_fields.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Add password login and lockout fields to user accounts.
|
||||
|
||||
Revision ID: 20260828_0006
|
||||
Revises: 20260828_0005
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260828_0006"
|
||||
down_revision: str | None = "20260828_0005"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("user_accounts", sa.Column("password_hash", sa.String(length=255), nullable=True))
|
||||
op.add_column("user_accounts", sa.Column("login_failed_count", sa.Integer(), server_default="0", nullable=False))
|
||||
op.add_column("user_accounts", sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True))
|
||||
op.execute("COMMENT ON COLUMN user_accounts.password_hash IS '本地密码哈希,不保存明文密码;钉钉用户可为空。'")
|
||||
op.execute("COMMENT ON COLUMN user_accounts.login_failed_count IS '连续登录失败次数,达到阈值后触发临时锁定。'")
|
||||
op.execute("COMMENT ON COLUMN user_accounts.locked_until IS '本地登录临时锁定截止时间。'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user_accounts", "locked_until")
|
||||
op.drop_column("user_accounts", "login_failed_count")
|
||||
op.drop_column("user_accounts", "password_hash")
|
||||
|
||||
30
migrations/versions/20260828_0007_add_user_email.py
Normal file
30
migrations/versions/20260828_0007_add_user_email.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Add optional enterprise email to user accounts.
|
||||
|
||||
Revision ID: 20260828_0007
|
||||
Revises: 20260828_0006
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260828_0007"
|
||||
down_revision: str | None = "20260828_0006"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("user_accounts", sa.Column("email", sa.String(length=128), nullable=True))
|
||||
op.create_unique_constraint("uq_user_accounts_email", "user_accounts", ["email"])
|
||||
op.create_index("ix_user_accounts_role_status", "user_accounts", ["role", "status"])
|
||||
op.execute("COMMENT ON COLUMN user_accounts.email IS '企业邮箱,可用于本地密码登录。'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_user_accounts_role_status", table_name="user_accounts")
|
||||
op.drop_constraint("uq_user_accounts_email", "user_accounts", type_="unique")
|
||||
op.drop_column("user_accounts", "email")
|
||||
|
||||
102
migrations/versions/20260828_0008_dingtalk_and_audit.py
Normal file
102
migrations/versions/20260828_0008_dingtalk_and_audit.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""Add DingTalk organization fields and operation audit records.
|
||||
|
||||
Revision ID: 20260828_0008
|
||||
Revises: 20260828_0007
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260828_0008"
|
||||
down_revision: str | None = "20260828_0007"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for column_name, column_type in (
|
||||
("dingtalk_union_id", sa.String(length=64)),
|
||||
("dingtalk_avatar", sa.String(length=512)),
|
||||
("dingtalk_job_number", sa.String(length=64)),
|
||||
("dingtalk_title", sa.String(length=128)),
|
||||
("dingtalk_dept_id", sa.BigInteger()),
|
||||
("dingtalk_is_dept_manager", sa.Boolean()),
|
||||
("dingtalk_role_codes", sa.String(length=512)),
|
||||
("last_login_at", sa.DateTime(timezone=True)),
|
||||
):
|
||||
if column_name == "dingtalk_is_dept_manager":
|
||||
op.add_column(
|
||||
"user_accounts",
|
||||
sa.Column(column_name, column_type, server_default="false", nullable=False),
|
||||
)
|
||||
else:
|
||||
op.add_column("user_accounts", sa.Column(column_name, column_type, nullable=True))
|
||||
|
||||
comments = {
|
||||
"dingtalk_union_id": "钉钉 UnionId。",
|
||||
"dingtalk_avatar": "钉钉头像地址。",
|
||||
"dingtalk_job_number": "钉钉组织中的工号。",
|
||||
"dingtalk_title": "钉钉组织中的岗位或职务。",
|
||||
"dingtalk_dept_id": "钉钉主部门编号。",
|
||||
"dingtalk_is_dept_manager": "是否为部门主管。",
|
||||
"dingtalk_role_codes": "钉钉角色编码快照。",
|
||||
"last_login_at": "最近一次登录时间。",
|
||||
}
|
||||
for column_name, comment in comments.items():
|
||||
op.execute(f"COMMENT ON COLUMN user_accounts.{column_name} IS '{comment}'")
|
||||
|
||||
op.create_table(
|
||||
"operation_audits",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("actor_user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("actor_emp_no", sa.String(length=20), nullable=True),
|
||||
sa.Column("action", sa.String(length=64), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("resource_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("result", sa.String(length=20), server_default="success", nullable=False),
|
||||
sa.Column(
|
||||
"metadata",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default="{}",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_operation_audits_actor_created",
|
||||
"operation_audits",
|
||||
["actor_user_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_operation_audits_action_created",
|
||||
"operation_audits",
|
||||
["action", "created_at"],
|
||||
)
|
||||
op.execute("COMMENT ON TABLE operation_audits IS '登录、权限、审核等敏感操作的追加式审计记录。'")
|
||||
op.execute(
|
||||
"COMMENT ON COLUMN operation_audits.metadata IS "
|
||||
"'审计上下文,禁止写入密码、令牌和完整敏感业务内容。'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_operation_audits_action_created", table_name="operation_audits")
|
||||
op.drop_index("ix_operation_audits_actor_created", table_name="operation_audits")
|
||||
op.drop_table("operation_audits")
|
||||
for column_name in (
|
||||
"last_login_at",
|
||||
"dingtalk_role_codes",
|
||||
"dingtalk_is_dept_manager",
|
||||
"dingtalk_dept_id",
|
||||
"dingtalk_title",
|
||||
"dingtalk_job_number",
|
||||
"dingtalk_avatar",
|
||||
"dingtalk_union_id",
|
||||
):
|
||||
op.drop_column("user_accounts", column_name)
|
||||
|
||||
76
migrations/versions/20260828_0009_seed_rbac_permissions.py
Normal file
76
migrations/versions/20260828_0009_seed_rbac_permissions.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Seed the PRD role permission matrix.
|
||||
|
||||
Revision ID: 20260828_0009
|
||||
Revises: 20260828_0008
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260828_0009"
|
||||
down_revision: str | None = "20260828_0008"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
PERMISSIONS = (
|
||||
("普通用户", "risk.assess", "L1", True, True),
|
||||
("普通用户", "risk_item.submit", "L1", True, True),
|
||||
("dev", "risk.assess", "L1", True, True),
|
||||
("dev", "risk_item.submit", "L1", True, True),
|
||||
("procurement", "risk.assess", "L1", True, True),
|
||||
("procurement", "supplier.read", "L2", True, False),
|
||||
("procurement", "risk_item.submit", "L1", True, True),
|
||||
("qc", "risk.assess", "L1", True, True),
|
||||
("qc", "risk_item.submit", "L1", True, True),
|
||||
("qc", "risk_item.review", "L2", True, False),
|
||||
("ops", "risk.assess", "L1", True, True),
|
||||
("ops", "risk_item.submit", "L1", True, True),
|
||||
("reviewer", "risk.assess", "L1", True, True),
|
||||
("reviewer", "risk_item.submit", "L1", True, True),
|
||||
("reviewer", "risk_item.review", "L2", True, True),
|
||||
("manager", "risk.assess", "L1", True, True),
|
||||
("manager", "supplier.read", "L2", True, False),
|
||||
("manager", "risk_item.submit", "L1", True, True),
|
||||
("manager", "risk_item.review", "L2", True, True),
|
||||
("admin", "risk.assess", "L3", True, True),
|
||||
("admin", "supplier.read", "L3", True, True),
|
||||
("admin", "risk_item.submit", "L3", True, True),
|
||||
("admin", "risk_item.review", "L3", True, True),
|
||||
("admin", "rbac.manage", "L3", True, True),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
statement = sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate)
|
||||
VALUES (:role, :func_key, :data_level, :can_view, :can_operate)
|
||||
ON CONFLICT (role, func_key) DO UPDATE SET
|
||||
data_level = EXCLUDED.data_level,
|
||||
can_view = EXCLUDED.can_view,
|
||||
can_operate = EXCLUDED.can_operate,
|
||||
updated_at = now()
|
||||
"""
|
||||
)
|
||||
for role, func_key, data_level, can_view, can_operate in PERMISSIONS:
|
||||
connection.execute(
|
||||
statement,
|
||||
{
|
||||
"role": role,
|
||||
"func_key": func_key,
|
||||
"data_level": data_level,
|
||||
"can_view": can_view,
|
||||
"can_operate": can_operate,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
statement = sa.text("DELETE FROM role_permissions WHERE role = :role AND func_key = :func_key")
|
||||
for role, func_key, *_ in PERMISSIONS:
|
||||
connection.execute(statement, {"role": role, "func_key": func_key})
|
||||
@ -0,0 +1,41 @@
|
||||
"""Allow business roles to execute risk assessments.
|
||||
|
||||
Revision ID: 20260830_0010
|
||||
Revises: 20260828_0009
|
||||
Create Date: 2026-08-30
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260830_0010"
|
||||
down_revision: str | None = "20260828_0009"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE role_permissions
|
||||
SET can_operate = true, updated_at = now()
|
||||
WHERE func_key = 'risk.assess'
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE role_permissions
|
||||
SET can_operate = false, updated_at = now()
|
||||
WHERE func_key = 'risk.assess' AND role <> 'admin'
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
58
migrations/versions/20260831_0011_add_conversations.py
Normal file
58
migrations/versions/20260831_0011_add_conversations.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""Add persistent assistant conversations and messages.
|
||||
|
||||
Revision ID: 20260831_0011
|
||||
Revises: 20260830_0010
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260831_0011"
|
||||
down_revision: str | None = "20260830_0010"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"conversations",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("title", sa.String(length=120), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user_accounts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_conversations_user_updated", "conversations", ["user_id", "updated_at"])
|
||||
|
||||
op.create_table(
|
||||
"conversation_messages",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("conversation_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"structured_data",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default="{}",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["conversation_id"], ["conversations.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_conversation_messages_conversation_created",
|
||||
"conversation_messages",
|
||||
["conversation_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("conversation_messages")
|
||||
op.drop_table("conversations")
|
||||
@ -0,0 +1,27 @@
|
||||
"""Add message type to persistent assistant messages.
|
||||
|
||||
Revision ID: 20260831_0012
|
||||
Revises: 20260831_0011
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260831_0012"
|
||||
down_revision: str | None = "20260831_0011"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"conversation_messages",
|
||||
sa.Column("message_type", sa.String(length=20), server_default="text", nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("conversation_messages", "message_type")
|
||||
@ -0,0 +1,56 @@
|
||||
"""Add permissions for the standard library."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260902_0013"
|
||||
down_revision: str | None = "20260831_0012"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
PERMISSIONS = (
|
||||
("普通用户", "standard.read", "L1", True, False),
|
||||
("dev", "standard.read", "L1", True, False),
|
||||
("procurement", "standard.read", "L1", True, False),
|
||||
("qc", "standard.read", "L1", True, False),
|
||||
("ops", "standard.read", "L1", True, False),
|
||||
("reviewer", "standard.read", "L1", True, False),
|
||||
("manager", "standard.read", "L1", True, False),
|
||||
("admin", "standard.read", "L3", True, True),
|
||||
("admin", "standard.manage", "L3", True, True),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
statement = sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate)
|
||||
VALUES (:role, :func_key, :data_level, :can_view, :can_operate)
|
||||
ON CONFLICT (role, func_key) DO UPDATE SET
|
||||
data_level = EXCLUDED.data_level,
|
||||
can_view = EXCLUDED.can_view,
|
||||
can_operate = EXCLUDED.can_operate,
|
||||
updated_at = now()
|
||||
"""
|
||||
)
|
||||
for role, func_key, data_level, can_view, can_operate in PERMISSIONS:
|
||||
connection.execute(
|
||||
statement,
|
||||
{
|
||||
"role": role,
|
||||
"func_key": func_key,
|
||||
"data_level": data_level,
|
||||
"can_view": can_view,
|
||||
"can_operate": can_operate,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
statement = sa.text("DELETE FROM role_permissions WHERE role = :role AND func_key = :func_key")
|
||||
for role, func_key, *_ in PERMISSIONS:
|
||||
connection.execute(statement, {"role": role, "func_key": func_key})
|
||||
48
pyproject.toml
Normal file
48
pyproject.toml
Normal file
@ -0,0 +1,48 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "dieya-risk-prediction-backend"
|
||||
version = "0.1.0"
|
||||
description = "Risk prediction service built with FastAPI"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11.9,<3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.13,<2.0",
|
||||
"asyncpg>=0.29,<1.0",
|
||||
"fastapi>=0.115,<1.0",
|
||||
"httpx>=0.27,<1.0",
|
||||
"openpyxl>=3.1,<4.0",
|
||||
"PyJWT>=2.8,<3.0",
|
||||
"pydantic>=2.8,<3.0",
|
||||
"pydantic-settings>=2.4,<3.0",
|
||||
"sqlalchemy[asyncio]>=2.0,<3.0",
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"aiosqlite>=0.20,<1.0",
|
||||
"httpx>=0.27,<1.0",
|
||||
"pytest>=8.0,<9.0",
|
||||
"pytest-asyncio>=0.23,<1.0",
|
||||
"ruff>=0.6,<1.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app*"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
src = ["app", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
addopts = "-ra"
|
||||
asyncio_mode = "auto"
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
||||
alembic>=1.13,<2.0
|
||||
asyncpg>=0.29,<1.0
|
||||
fastapi>=0.115,<1.0
|
||||
httpx>=0.27,<1.0
|
||||
openpyxl>=3.1,<4.0
|
||||
PyJWT>=2.8,<3.0
|
||||
pydantic>=2.8,<3.0
|
||||
pydantic-settings>=2.4,<3.0
|
||||
sqlalchemy[asyncio]>=2.0,<3.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
2
scripts/__init__.py
Normal file
2
scripts/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Operational scripts for the risk prediction backend."""
|
||||
|
||||
61
scripts/create_user.py
Normal file
61
scripts/create_user.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Create or update a local user account with a hashed password."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_password
|
||||
from app.models.entities import UserAccount
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
|
||||
async def create_or_update_user(
|
||||
emp_no: str,
|
||||
name: str,
|
||||
password: str,
|
||||
role: str,
|
||||
email: str | None,
|
||||
) -> int:
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
user = await session.scalar(select(UserAccount).where(UserAccount.emp_no == emp_no))
|
||||
if user is None:
|
||||
user = UserAccount(emp_no=emp_no, name=name, role=role, status="启用")
|
||||
session.add(user)
|
||||
user.name = name
|
||||
user.role = role
|
||||
user.status = "启用"
|
||||
user.password_hash = hash_password(password)
|
||||
user.email = email
|
||||
await session.commit()
|
||||
return user.id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--emp-no", required=True, help="Employee number used for login")
|
||||
parser.add_argument("--name", required=True, help="Display name")
|
||||
parser.add_argument("--password", required=True, help="Plain password; only its hash is stored")
|
||||
parser.add_argument(
|
||||
"--role",
|
||||
default="admin",
|
||||
choices=("普通用户", "dev", "procurement", "qc", "ops", "reviewer", "manager", "admin"),
|
||||
)
|
||||
parser.add_argument("--email")
|
||||
args = parser.parse_args()
|
||||
if len(args.password) < 8:
|
||||
parser.error("password must be at least 8 characters")
|
||||
user_id = asyncio.run(create_or_update_user(args.emp_no, args.name, args.password, args.role, args.email))
|
||||
print(f"user_id={user_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
195
scripts/import_excel_data.py
Normal file
195
scripts/import_excel_data.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""Import the supplied anomaly and judgment-standard workbooks.
|
||||
|
||||
The import is intentionally idempotent: every source row gets a stable SHA-256
|
||||
fingerprint based on its file, worksheet, and Excel row number.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterable, Sequence
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.entities import AnomalyCase, StandardRule
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
LOGGER = logging.getLogger("dieya_risk_prediction.import")
|
||||
|
||||
ANOMALY_SHEET = "异常总览(新流程分类)"
|
||||
STANDARD_SHEETS = ("包装", "包材", "法规", "内料")
|
||||
|
||||
|
||||
def clean_text(value: Any) -> str | None:
|
||||
"""Normalize spreadsheet text while preserving meaningful content."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).replace("\u200b", "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def parse_date(value: Any) -> date | None:
|
||||
"""Parse the date formats used by the source workbook."""
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
text = clean_text(value)
|
||||
if not text:
|
||||
return None
|
||||
text = re.sub(r"\s+00:00:00$", "", text)
|
||||
text = text.replace("年", "-").replace("月", "-").replace("日", "")
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
month_only = re.fullmatch(r"(\d{4})[-/.](\d{1,2})", text)
|
||||
if month_only:
|
||||
return date(int(month_only.group(1)), int(month_only.group(2)), 1)
|
||||
return None
|
||||
|
||||
|
||||
def fingerprint(file_name: str, sheet_name: str, row_number: int) -> str:
|
||||
source = f"{file_name}\x1f{sheet_name}\x1f{row_number}"
|
||||
return hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def row_values(row: Sequence[Any], size: int) -> list[Any]:
|
||||
"""Return a fixed-size row so trailing empty cells are handled uniformly."""
|
||||
|
||||
values = list(row)
|
||||
return values[:size] + [None] * max(0, size - len(values))
|
||||
|
||||
|
||||
def read_anomaly_rows(path: Path) -> list[dict[str, Any]]:
|
||||
workbook = load_workbook(path, data_only=True)
|
||||
sheet = workbook[ANOMALY_SHEET]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for excel_row, raw_row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
|
||||
values = row_values(raw_row, 22)
|
||||
rows.append(
|
||||
{
|
||||
"date": parse_date(values[1]),
|
||||
"product_code": clean_text(values[2]),
|
||||
"product_name": clean_text(values[3]),
|
||||
"brand": clean_text(values[4]),
|
||||
"specification": clean_text(values[5]),
|
||||
"stage_new": clean_text(values[6]),
|
||||
"stage_old": clean_text(values[7]),
|
||||
"anomaly_type": clean_text(values[8]),
|
||||
"material_type": clean_text(values[9]),
|
||||
"supplier": clean_text(values[10]),
|
||||
"factory": clean_text(values[11]),
|
||||
"person": clean_text(values[12]),
|
||||
"description": clean_text(values[13]),
|
||||
"root_cause": clean_text(values[14]),
|
||||
"solution": clean_text(values[15]),
|
||||
"result": clean_text(values[16]),
|
||||
"responsible": clean_text(values[17]),
|
||||
"defect_level": clean_text(values[18]) if clean_text(values[18]) != "/" else None,
|
||||
"is_resolved": clean_text(values[19]),
|
||||
"source_file": clean_text(values[20]) or path.name,
|
||||
"remark": clean_text(values[21]),
|
||||
"source_type": "Excel导入",
|
||||
"import_fingerprint": fingerprint(path.name, ANOMALY_SHEET, excel_row),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def read_standard_rows(path: Path) -> list[dict[str, Any]]:
|
||||
workbook = load_workbook(path, data_only=True)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for sheet_name in STANDARD_SHEETS:
|
||||
sheet = workbook[sheet_name]
|
||||
for excel_row, raw_row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
|
||||
values = row_values(raw_row, 9)
|
||||
if not any(value is not None and str(value).strip() for value in values):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"category_lv1": clean_text(values[1]) or sheet_name,
|
||||
"category_lv2": clean_text(values[2]),
|
||||
"category_lv3": clean_text(values[3]),
|
||||
"risk_point": clean_text(values[4]) or "未填写",
|
||||
"judge_standard": clean_text(values[5]) or "未填写",
|
||||
"regulation": clean_text(values[6]),
|
||||
"attachment": " | ".join(
|
||||
value for value in (clean_text(values[7]), clean_text(values[8])) if value
|
||||
) or None,
|
||||
"status": "启用",
|
||||
"import_fingerprint": fingerprint(path.name, sheet_name, excel_row),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def insert_ignore_duplicates(
|
||||
session: AsyncSession,
|
||||
model: type[AnomalyCase] | type[StandardRule],
|
||||
rows: Iterable[dict[str, Any]],
|
||||
) -> tuple[int, int]:
|
||||
payload = list(rows)
|
||||
if not payload:
|
||||
return 0, 0
|
||||
inserted = 0
|
||||
batch_size = 100
|
||||
for start in range(0, len(payload), batch_size):
|
||||
batch = payload[start : start + batch_size]
|
||||
statement = insert(model).values(batch).on_conflict_do_nothing(index_elements=["import_fingerprint"])
|
||||
result = await session.execute(statement)
|
||||
inserted += result.rowcount or 0
|
||||
return inserted, len(payload) - inserted
|
||||
|
||||
|
||||
async def import_workbooks(anomaly_path: Path, standard_path: Path) -> dict[str, int]:
|
||||
anomaly_rows = read_anomaly_rows(anomaly_path)
|
||||
standard_rows = read_standard_rows(standard_path)
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
anomaly_inserted, anomaly_skipped = await insert_ignore_duplicates(session, AnomalyCase, anomaly_rows)
|
||||
standard_inserted, standard_skipped = await insert_ignore_duplicates(session, StandardRule, standard_rows)
|
||||
await session.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
return {
|
||||
"anomaly_source_rows": len(anomaly_rows),
|
||||
"anomaly_inserted": anomaly_inserted,
|
||||
"anomaly_skipped": anomaly_skipped,
|
||||
"standard_source_rows": len(standard_rows),
|
||||
"standard_inserted": standard_inserted,
|
||||
"standard_skipped": standard_skipped,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--anomaly-file", type=Path, required=True)
|
||||
parser.add_argument("--standard-file", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
|
||||
if not args.anomaly_file.is_file():
|
||||
parser.error(f"anomaly file does not exist: {args.anomaly_file}")
|
||||
if not args.standard_file.is_file():
|
||||
parser.error(f"standard file does not exist: {args.standard_file}")
|
||||
result = asyncio.run(import_workbooks(args.anomaly_file, args.standard_file))
|
||||
for key, value in result.items():
|
||||
print(f"{key}={value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
services_dummy.txt
Normal file
0
services_dummy.txt
Normal file
2
tests/__init__.py
Normal file
2
tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Backend tests."""
|
||||
|
||||
10
tests/test_auth.py
Normal file
10
tests/test_auth.py
Normal file
@ -0,0 +1,10 @@
|
||||
from app.services.dingtalk_auth import DingTalkAuthService
|
||||
|
||||
|
||||
def test_dingtalk_role_mapping() -> None:
|
||||
assert DingTalkAuthService.map_role("产品开发部", "工程师") == "dev"
|
||||
assert DingTalkAuthService.map_role("品控部", "质量主管") == "qc"
|
||||
assert DingTalkAuthService.map_role("采购部", "采购专员") == "procurement"
|
||||
assert DingTalkAuthService.map_role("", "审核员") == "reviewer"
|
||||
assert DingTalkAuthService.map_role("", "", is_admin=True) == "admin"
|
||||
assert DingTalkAuthService.map_role("未知部门", "普通员工") == "普通用户"
|
||||
45
tests/test_conversation_context.py
Normal file
45
tests/test_conversation_context.py
Normal file
@ -0,0 +1,45 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models.entities import Conversation, ConversationMessage
|
||||
from app.services.conversations import ConversationService
|
||||
|
||||
|
||||
def test_history_context_does_not_duplicate_current_user_message() -> None:
|
||||
current_text = "继续分析这个产品的供应商风险"
|
||||
conversation = Conversation(
|
||||
id=1,
|
||||
user_id=1,
|
||||
title="产品风险",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
messages=[
|
||||
ConversationMessage(
|
||||
id=1,
|
||||
conversation_id=1,
|
||||
role="user",
|
||||
content="评估一款软管新品",
|
||||
created_at=datetime.now(UTC),
|
||||
),
|
||||
ConversationMessage(
|
||||
id=2,
|
||||
conversation_id=1,
|
||||
role="assistant",
|
||||
content="已识别为软管新品风险评估。",
|
||||
created_at=datetime.now(UTC),
|
||||
),
|
||||
ConversationMessage(
|
||||
id=3,
|
||||
conversation_id=1,
|
||||
role="user",
|
||||
content=current_text,
|
||||
created_at=datetime.now(UTC),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
history = ConversationService._history_messages(conversation, current_text=current_text)
|
||||
|
||||
assert [item["content"] for item in history] == [
|
||||
"评估一款软管新品",
|
||||
"已识别为软管新品风险评估。",
|
||||
]
|
||||
121
tests/test_conversations.py
Normal file
121
tests/test_conversations.py
Normal file
@ -0,0 +1,121 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models.entities import Conversation, ConversationMessage
|
||||
from app.services.conversations import ConversationService
|
||||
|
||||
|
||||
def test_conversation_message_data_reads_persisted_risk_result() -> None:
|
||||
message = ConversationMessage(
|
||||
id=10,
|
||||
conversation_id=3,
|
||||
role="assistant",
|
||||
content="已完成风险预估。",
|
||||
structured_data={
|
||||
"riskResult": {
|
||||
"subjectId": "软管新品",
|
||||
"score": 82,
|
||||
"level": "low",
|
||||
"modelVersion": "baseline-safety-v2",
|
||||
"factors": [],
|
||||
}
|
||||
},
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
result = ConversationService._message(message)
|
||||
|
||||
assert result.role == "assistant"
|
||||
assert result.risk_result is not None
|
||||
assert result.risk_result.score == 82
|
||||
assert result.risk_result.level.value == "low"
|
||||
|
||||
|
||||
def test_conversation_data_includes_all_messages() -> None:
|
||||
conversation = Conversation(
|
||||
id=3,
|
||||
user_id=1,
|
||||
title="软管新品评估",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
messages=[
|
||||
ConversationMessage(
|
||||
id=1,
|
||||
conversation_id=3,
|
||||
role="user",
|
||||
content="评估软管新品",
|
||||
created_at=datetime.now(UTC),
|
||||
),
|
||||
ConversationMessage(
|
||||
id=2,
|
||||
conversation_id=3,
|
||||
role="assistant",
|
||||
content="已完成分析",
|
||||
created_at=datetime.now(UTC),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = ConversationService._data(conversation)
|
||||
|
||||
assert result.message_count == 2
|
||||
assert [message.role for message in result.messages] == ["user", "assistant"]
|
||||
|
||||
|
||||
def test_only_explicit_risk_requests_generate_risk_cards() -> None:
|
||||
assert ConversationService._is_risk_request(
|
||||
"你好",
|
||||
{"intent": "greet", "risk_requested": False},
|
||||
) is False
|
||||
assert ConversationService._is_risk_request(
|
||||
"请只回复:AI 连通成功。",
|
||||
{"intent": "general", "risk_requested": False},
|
||||
) is False
|
||||
assert ConversationService._is_risk_request(
|
||||
"请评估这个产品的风险",
|
||||
{"intent": "assessment", "risk_requested": True},
|
||||
) is True
|
||||
|
||||
|
||||
def test_legacy_general_semantics_hide_old_risk_card() -> None:
|
||||
messages = [
|
||||
ConversationMessage(
|
||||
id=1,
|
||||
conversation_id=1,
|
||||
role="user",
|
||||
content="你好",
|
||||
created_at=datetime.now(UTC),
|
||||
),
|
||||
ConversationMessage(
|
||||
id=2,
|
||||
conversation_id=1,
|
||||
role="assistant",
|
||||
content="你好,我可以帮你做什么?",
|
||||
structured_data={
|
||||
"semantic": {"intent": "general"},
|
||||
"riskResult": {
|
||||
"subjectId": "误判",
|
||||
"score": 61,
|
||||
"level": "moderately_low",
|
||||
"modelVersion": "baseline-safety-v2",
|
||||
"factors": [],
|
||||
},
|
||||
},
|
||||
created_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
|
||||
assert ConversationService._risk_result_for_message(messages, 1) is None
|
||||
assert ConversationService._is_risk_request(
|
||||
"我要做一款脱毛膏新品,100g,PE软管包装,加工厂准备使用瑞沣",
|
||||
{"intent": "general", "risk_requested": False},
|
||||
) is True
|
||||
|
||||
|
||||
def test_product_assessment_semantics_override_nlu_miss() -> None:
|
||||
normalized = ConversationService._normalize_semantics(
|
||||
"我要做一款脱毛膏新品,100g,PE软管包装,加工厂准备使用瑞沣",
|
||||
{"intent": "general", "risk_requested": False, "category": "脱毛膏"},
|
||||
)
|
||||
|
||||
assert normalized["intent"] == "assessment"
|
||||
assert normalized["risk_requested"] is True
|
||||
18
tests/test_health.py
Normal file
18
tests/test_health.py
Normal file
@ -0,0 +1,18 @@
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health_check() -> None:
|
||||
response = TestClient(app).get("/api/v1/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"status": "ok",
|
||||
"service": "Dieya Risk Prediction Backend",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
}
|
||||
|
||||
38
tests/test_llm_client.py
Normal file
38
tests/test_llm_client.py
Normal file
@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import AppException
|
||||
from app.services.llm_client import LLMClient
|
||||
|
||||
|
||||
def test_llm_endpoint_supports_openai_v1_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setenv("RISK_LLM_API_KEY", "test-key")
|
||||
monkeypatch.setenv("RISK_LLM_BASE_URL", "https://example.test/v1")
|
||||
client = LLMClient()
|
||||
|
||||
assert client.configured
|
||||
assert client.endpoint == "https://example.test/v1/chat/completions"
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_llm_client_accepts_reference_project_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.delenv("RISK_LLM_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "reference-key")
|
||||
monkeypatch.setenv("LLM_BASE_URL", "https://reference.test/v1")
|
||||
monkeypatch.setenv("LLM_MODEL", "reference-model")
|
||||
client = LLMClient()
|
||||
|
||||
assert client.configured
|
||||
assert client.endpoint == "https://reference.test/v1/chat/completions"
|
||||
assert client.model == "reference-model"
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_llm_client_parses_json_and_rejects_non_object() -> None:
|
||||
parsed = LLMClient._parse_json('```json\n{"intent":"assessment","confidence":0.9}\n```')
|
||||
|
||||
assert parsed["intent"] == "assessment"
|
||||
assert parsed["confidence"] == 0.9
|
||||
with pytest.raises(AppException):
|
||||
LLMClient._parse_json("[]")
|
||||
57
tests/test_llm_protocol.py
Normal file
57
tests/test_llm_protocol.py
Normal file
@ -0,0 +1,57 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from app.core.config import get_settings
|
||||
from app.services.llm_client import LLMClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setenv("RISK_LLM_API_KEY", "test-key")
|
||||
monkeypatch.setenv("RISK_LLM_BASE_URL", "https://llm.test/v1")
|
||||
monkeypatch.setenv("RISK_LLM_MODEL", "test-model")
|
||||
calls = 0
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": json.dumps(
|
||||
{"intent": "assessment", "category": "洗发水", "confidence": 0.92},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=(
|
||||
'data: {"choices":[{"delta":{"content":"第一段"}}]}\n\n'
|
||||
'data: {"choices":[{"delta":{"content":"第二段"}}]}\n\n'
|
||||
"data: [DONE]\n\n"
|
||||
).encode(),
|
||||
)
|
||||
|
||||
client = LLMClient(transport=httpx.MockTransport(handler))
|
||||
semantic = await client.extract_semantics("评估洗发水新品")
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])
|
||||
]
|
||||
|
||||
assert semantic["intent"] == "assessment"
|
||||
assert semantic["confidence"] == 0.92
|
||||
assert "".join(chunks) == "第一段第二段"
|
||||
assert calls == 2
|
||||
get_settings.cache_clear()
|
||||
49
tests/test_models.py
Normal file
49
tests/test_models.py
Normal file
@ -0,0 +1,49 @@
|
||||
from app.database.base import Base
|
||||
from app.models import ( # noqa: F401
|
||||
AnomalyCase,
|
||||
Conversation,
|
||||
ConversationMessage,
|
||||
OperationAudit,
|
||||
ReviewRecord,
|
||||
RiskAssessment,
|
||||
RiskItem,
|
||||
RolePermission,
|
||||
StandardRule,
|
||||
SupplierProfile,
|
||||
UserAccount,
|
||||
)
|
||||
from sqlalchemy import BigInteger
|
||||
|
||||
|
||||
def test_prd_core_tables_are_registered() -> None:
|
||||
assert set(Base.metadata.tables) == {
|
||||
"anomaly_cases",
|
||||
"conversations",
|
||||
"conversation_messages",
|
||||
"operation_audits",
|
||||
"review_records",
|
||||
"risk_assessments",
|
||||
"risk_items",
|
||||
"role_permissions",
|
||||
"standard_rules",
|
||||
"supplier_profiles",
|
||||
"user_accounts",
|
||||
}
|
||||
|
||||
|
||||
def test_prd_tables_use_bigint_primary_keys() -> None:
|
||||
for table in Base.metadata.sorted_tables:
|
||||
assert isinstance(table.c.id.type, BigInteger)
|
||||
assert table.c.id.primary_key
|
||||
|
||||
|
||||
def test_review_and_standard_foreign_keys_are_registered() -> None:
|
||||
review_targets = {
|
||||
foreign_key.target_fullname for foreign_key in Base.metadata.tables["review_records"].foreign_keys
|
||||
}
|
||||
anomaly_targets = {
|
||||
foreign_key.target_fullname for foreign_key in Base.metadata.tables["anomaly_cases"].foreign_keys
|
||||
}
|
||||
|
||||
assert review_targets == {"risk_items.id", "anomaly_cases.id"}
|
||||
assert anomaly_targets == {"standard_rules.id"}
|
||||
47
tests/test_risk_prediction.py
Normal file
47
tests/test_risk_prediction.py
Normal file
@ -0,0 +1,47 @@
|
||||
from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_preview_requires_authentication() -> None:
|
||||
response = TestClient(app).post(
|
||||
"/api/v1/risk-predictions/preview",
|
||||
json={
|
||||
"subjectId": "customer-001",
|
||||
"indicators": [
|
||||
{"name": "debtBurden", "value": 80, "weight": 3},
|
||||
{"name": "cashflowVolatility", "value": 20, "weight": 1},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_baseline_engine_calculates_weighted_score() -> None:
|
||||
score = BaselineRiskEngine().predict(
|
||||
[
|
||||
IndicatorValue(name="debtBurden", value=80, weight=3),
|
||||
IndicatorValue(name="cashflowVolatility", value=20, weight=1),
|
||||
]
|
||||
)
|
||||
|
||||
assert score.score == 35.0
|
||||
assert score.level.value == "high"
|
||||
assert [factor.contribution for factor in score.factors] == [15.0, 20.0]
|
||||
|
||||
|
||||
def test_risk_level_boundaries_follow_prd() -> None:
|
||||
engine = BaselineRiskEngine()
|
||||
cases = (
|
||||
(80, "low"),
|
||||
(60, "moderately_low"),
|
||||
(40, "medium"),
|
||||
(20, "high"),
|
||||
(19.99, "very_high"),
|
||||
)
|
||||
|
||||
for score, expected_level in cases:
|
||||
result = engine.predict([IndicatorValue(name="risk", value=100 - score, weight=1)])
|
||||
assert result.score == score
|
||||
assert result.level.value == expected_level
|
||||
Loading…
x
Reference in New Issue
Block a user