feat: 实现后端核心业务逻辑 (Auth鉴权、二维码服务、看板与产品端点)

This commit is contained in:
2026-08-04 17:09:47 +08:00
parent 2b967afbf6
commit b22de514a1
11 changed files with 407 additions and 0 deletions

View File

@ -0,0 +1 @@
"""API v1 端点"""

View File

@ -0,0 +1,23 @@
"""认证 API — 对接 MOM sys_user"""
from fastapi import APIRouter, Depends
from app.schemas.user import LoginRequest, LoginResponse, UserResponse
from app.services.auth_service import login, get_current_user
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=LoginResponse)
def login_endpoint(data: LoginRequest):
"""登录 — 验证 MOM sys_user 表,返回 JWT"""
return login(data.username, data.password)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: dict = Depends(get_current_user)):
"""获取当前用户信息(从 JWT 解析)"""
return UserResponse(
id=current_user["sub"],
username=current_user.get("username", ""),
display_name=current_user.get("display_name", ""),
role=current_user.get("role", "operator"),
)

View File

@ -0,0 +1,12 @@
"""Dashboard API"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.dashboard_service import get_dashboard_stats, DashboardStats
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
@router.get("/stats", response_model=DashboardStats)
async def dashboard_stats(db: AsyncSession = Depends(get_db)):
return await get_dashboard_stats(db)

View File

@ -0,0 +1,35 @@
"""生产订单 API 端点"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.production_order import ProductionOrder
from app.schemas.order import OrderCreate, OrderResponse
router = APIRouter(prefix="/orders", tags=["订单管理"])
@router.get("/", response_model=list[OrderResponse])
async def list_orders(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ProductionOrder).offset(skip).limit(limit).order_by(ProductionOrder.created_at.desc())
)
orders = result.scalars().all()
return [OrderResponse.model_validate(o) for o in orders]
@router.post("/", response_model=OrderResponse, status_code=201)
async def create_order(data: OrderCreate, db: AsyncSession = Depends(get_db)):
order = ProductionOrder(**data.model_dump())
db.add(order)
await db.commit()
await db.refresh(order)
return OrderResponse.model_validate(order)

View File

@ -0,0 +1,98 @@
"""产品 API 端点 — 扫码查询、CRUD、二维码生成"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.schemas.product import (
ProductCreate,
ProductUpdate,
ProductResponse,
ProductScanResponse,
)
from app.services import product_service
from app.services.qrcode_service import generate_qrcode_png
router = APIRouter(prefix="/products", tags=["产品管理"])
# ============================================================
# 二维码生成 — 根据序列号生成二维码 PNG 图片
# ============================================================
@router.get("/qrcode/{serial_number}")
async def get_product_qrcode(serial_number: str):
"""
生成产品二维码PNG 图片)。
内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。
尺寸300×300 px用于 PC 端打印或嵌入标签。
"""
if len(serial_number) != 16:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="序列号必须为 16 位",
)
buf = generate_qrcode_png(serial_number, size_px=300)
return Response(content=buf.getvalue(), media_type="image/png")
# ============================================================
# 扫码查询 — 根据 16 位序列号查产品 + 顶层任务
# ============================================================
@router.get("/scan/{serial_number}", response_model=ProductScanResponse)
async def scan_product(
serial_number: str,
db: AsyncSession = Depends(get_db),
):
"""
扫码接口:根据 16 位序列号查询产品及其当前进度。
返回产品信息、所属订单、以及顶层任务列表。
"""
return await product_service.get_product_by_serial(db, serial_number)
# ============================================================
# 产品 CRUD
# ============================================================
@router.get("/", response_model=list[ProductResponse])
async def list_products(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(50, ge=1, le=200, description="返回条数"),
db: AsyncSession = Depends(get_db),
):
"""获取产品列表"""
return await product_service.get_all_products(db, skip=skip, limit=limit)
@router.get("/{product_id}", response_model=ProductResponse)
async def get_product(
product_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取单个产品详情"""
import uuid
return await product_service.get_product(db, uuid.UUID(product_id))
@router.post("/", response_model=ProductResponse, status_code=201)
async def create_product_endpoint(
data: ProductCreate,
db: AsyncSession = Depends(get_db),
):
"""创建产品"""
return await product_service.create_product(db, data)
@router.patch("/{product_id}", response_model=ProductResponse)
async def update_product_endpoint(
product_id: str,
data: ProductUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新产品"""
import uuid
return await product_service.update_product(db, uuid.UUID(product_id), data)

View File

@ -0,0 +1,24 @@
"""生产订单 Pydantic Schemas"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class OrderCreate(BaseModel):
"""创建订单"""
order_no: str = Field(..., max_length=64, description="订单编号")
customer_info: str | None = Field(None, max_length=500, description="客户信息")
model_config = {"from_attributes": True}
class OrderResponse(BaseModel):
"""订单响应"""
id: uuid.UUID
order_no: str
customer_info: str | None
status: str
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,17 @@
"""任务操作日志 Pydantic Schemas"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class TaskLogResponse(BaseModel):
"""任务日志响应"""
id: uuid.UUID
task_id: uuid.UUID
operator_id: str | None
action_type: str
remark: str | None
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,24 @@
"""用户 Schemas — 对接 MOM sys_user 表"""
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(..., max_length=64)
password: str = Field(..., max_length=128)
class UserResponse(BaseModel):
id: str
username: str
display_name: str
role: str
is_active: bool = True
created_at: str | None = None
model_config = {"from_attributes": True}
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserResponse

View File

@ -0,0 +1,98 @@
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT"""
from fastapi import HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from werkzeug.security import check_password_hash
from app.core.config import settings
from app.core.security import create_access_token, ALGORITHM
from app.core.mom_database import MomSessionLocal
from app.schemas.user import LoginResponse, UserResponse
security = HTTPBearer()
def login(username: str, password: str) -> LoginResponse:
"""登录 — 查询 MOM 数据库 sys_user 表验证"""
db = MomSessionLocal()
try:
# 1. 超级管理员硬编码(和 MOM 系统一致)
if username == "IRIS" and password == "123321":
return LoginResponse(
access_token=create_access_token(
data={"sub": "0", "role": "SUPER_ADMIN"}
),
user=UserResponse(
id="0",
username="IRIS",
display_name="超级管理员",
role="SUPER_ADMIN",
),
)
# 2. 普通用户LIKE '%/username' 模糊匹配 MOM sys_user 表
from sqlalchemy import text
result = db.execute(
text(
"SELECT id, username, department, role, password_hash "
"FROM sys_user "
"WHERE username LIKE :pattern"
),
{"pattern": f"%/{username}"},
)
row = result.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
user_id, full_username, department, role, password_hash = row
# 3. Werkzeug scrypt 密码验证
if not check_password_hash(password_hash, password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
# 4. 解析 display_name"张三/zhangsan01" → "张三"
display_name = full_username.split("/")[0] if "/" in full_username else full_username
token = create_access_token(
data={
"sub": str(user_id),
"role": role or "operator",
"username": username,
"display_name": display_name,
}
)
return LoginResponse(
access_token=token,
user=UserResponse(
id=str(user_id),
username=username,
display_name=display_name,
role=role or "operator",
),
)
finally:
db.close()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""从 Bearer Token 解析当前用户(不查数据库,直接解 JWT"""
token = credentials.credentials
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="无效的 Token")
return payload
except JWTError:
raise HTTPException(status_code=401, detail="无效的 Token")

View File

@ -0,0 +1,41 @@
"""Dashboard 统计服务"""
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
class DashboardStats(BaseModel):
products_total: int
products_pending: int
products_in_progress: int
products_completed: int
tasks_total: int
tasks_pending: int
tasks_in_progress: int
tasks_completed: int
async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
from app.models.product import Product
from app.models.task import Task
p_total = await db.scalar(select(func.count(Product.id)))
p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending"))
p_progress = await db.scalar(select(func.count(Product.id)).where(Product.status == "in_progress"))
p_done = await db.scalar(select(func.count(Product.id)).where(Product.status == "completed"))
t_total = await db.scalar(select(func.count(Task.id)))
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == "pending"))
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == "in_progress"))
t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == "completed"))
return DashboardStats(
products_total=p_total or 0,
products_pending=p_pending or 0,
products_in_progress=p_progress or 0,
products_completed=p_done or 0,
tasks_total=t_total or 0,
tasks_pending=t_pending or 0,
tasks_in_progress=t_progress or 0,
tasks_completed=t_done or 0,
)

View File

@ -0,0 +1,34 @@
"""二维码生成服务 — 参考 MOM 系统 label_service.py 的 QR 生成逻辑"""
import io
import qrcode
from qrcode.image.pil import PilImage
def generate_qrcode_png(content: str, size_px: int = 300) -> io.BytesIO:
"""
生成二维码 PNG 图片,返回 BytesIO 流。
参数:
content: 二维码内容(如 16 位序列号)
size_px: 输出图片尺寸(像素),默认 300×300
返回:
io.BytesIO: PNG 格式的图片字节流
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=2,
)
qr.add_data(content)
qr.make(fit=True)
img: PilImage = qr.make_image(fill_color="black", back_color="white")
img = img.convert("RGB")
img = img.resize((size_px, size_px))
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return buf