feat: 实现后端核心业务逻辑 (Auth鉴权、二维码服务、看板与产品端点)
This commit is contained in:
1
backend/app/api/v1/endpoints/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""API v1 端点"""
|
||||
23
backend/app/api/v1/endpoints/auth.py
Normal file
23
backend/app/api/v1/endpoints/auth.py
Normal 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"),
|
||||
)
|
||||
12
backend/app/api/v1/endpoints/dashboard.py
Normal file
12
backend/app/api/v1/endpoints/dashboard.py
Normal 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)
|
||||
35
backend/app/api/v1/endpoints/orders.py
Normal file
35
backend/app/api/v1/endpoints/orders.py
Normal 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)
|
||||
98
backend/app/api/v1/endpoints/products.py
Normal file
98
backend/app/api/v1/endpoints/products.py
Normal 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)
|
||||
24
backend/app/schemas/order.py
Normal file
24
backend/app/schemas/order.py
Normal 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}
|
||||
17
backend/app/schemas/task_log.py
Normal file
17
backend/app/schemas/task_log.py
Normal 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}
|
||||
24
backend/app/schemas/user.py
Normal file
24
backend/app/schemas/user.py
Normal 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
|
||||
98
backend/app/services/auth_service.py
Normal file
98
backend/app/services/auth_service.py
Normal 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")
|
||||
41
backend/app/services/dashboard_service.py
Normal file
41
backend/app/services/dashboard_service.py
Normal 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,
|
||||
)
|
||||
34
backend/app/services/qrcode_service.py
Normal file
34
backend/app/services/qrcode_service.py
Normal 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
|
||||
Reference in New Issue
Block a user