风险预警

This commit is contained in:
kx_guo 2026-09-04 10:20:40 +08:00
commit ec8b83d015
85 changed files with 5876 additions and 0 deletions

19
.env.example Normal file
View 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
View File

@ -0,0 +1,13 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
.env
*.egg-info/
dist/
build/
data/

View 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(

View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
"""Dieya risk prediction backend package."""

2
app/api/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""API package."""

59
app/api/dependencies.py Normal file
View 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
View 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
View File

@ -0,0 +1,2 @@
"""API version 1 package."""

72
app/api/v1/auth.py Normal file
View 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
View 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
View 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
View 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
View 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))

View 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
View 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
View File

@ -0,0 +1,2 @@
"""Application core package."""

92
app/core/config.py Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
"""Database package."""

39
app/database/base.py Normal file
View 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
View 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
View File

@ -0,0 +1,2 @@
"""Domain package."""

71
app/domain/risk_engine.py Normal file
View 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
View 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
View 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
View 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)

View File

@ -0,0 +1,8 @@
"""Compatibility export for the PRD risk assessment model."""
from app.models.entities import RiskAssessment
RiskPrediction = RiskAssessment
__all__ = ["RiskAssessment", "RiskPrediction"]

View File

@ -0,0 +1,6 @@
"""Repository exports."""
from app.repositories.risk_prediction import RiskPredictionRepository
__all__ = ["RiskPredictionRepository"]

25
app/repositories/base.py Normal file
View 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)

View 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)

View 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
View 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
View File

@ -0,0 +1,2 @@
"""Pydantic schema package."""

44
app/schemas/auth.py Normal file
View 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
View 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

View 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
View 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
View 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

View 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
View 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
View File

@ -0,0 +1,2 @@
"""Service package."""

102
app/services/auth.py Normal file
View 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)

File diff suppressed because it is too large Load Diff

389
app/services/dashboard.py Normal file
View 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]

View 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
View 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
View 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)

View 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
View 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
View 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
View 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
View 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"}

View 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")

View 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")

View File

@ -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")

View File

@ -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.")

View File

@ -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.")

View 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")

View 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")

View 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)

View 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})

View File

@ -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'
"""
)
)

View 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")

View File

@ -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")

View File

@ -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
View 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
View 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
View File

@ -0,0 +1,2 @@
"""Operational scripts for the risk prediction backend."""

61
scripts/create_user.py Normal file
View 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()

View 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
View File

2
tests/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""Backend tests."""

10
tests/test_auth.py Normal file
View 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("未知部门", "普通员工") == "普通用户"

View 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
View 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
View 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
View 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("[]")

View 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
View 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"}

View 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