feat: 双Token认证(Access 2h/Refresh 7d) + 通知系统(转交/驳回自动推送)

This commit is contained in:
2026-08-07 11:44:04 +08:00
parent dc97a7385f
commit b71c5a2d07
16 changed files with 426 additions and 41 deletions

View File

@ -1,11 +1,18 @@
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT"""
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT(双 Token 架构)"""
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.security import (
create_access_token,
create_refresh_token,
decode_token,
ALGORITHM,
TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH,
)
from app.core.mom_database import MomSessionLocal
from app.schemas.user import LoginResponse, UserResponse
@ -13,15 +20,15 @@ security = HTTPBearer()
def login(username: str, password: str) -> LoginResponse:
"""登录 — 查询 MOM 数据库 sys_user 表验证"""
"""登录 — 签发双 Token(Access + Refresh)"""
db = MomSessionLocal()
try:
# 1. 超级管理员硬编码(和 MOM 系统一致)
if username == "IRIS" and password == "123321":
token_data = {"sub": "0", "role": "SUPER_ADMIN", "username": "IRIS", "display_name": "超级管理员"}
return LoginResponse(
access_token=create_access_token(
data={"sub": "0", "role": "SUPER_ADMIN"}
),
access_token=create_access_token(data=token_data),
refresh_token=create_refresh_token(data=token_data),
user=UserResponse(
id="0",
username="IRIS",
@ -60,17 +67,16 @@ def login(username: str, password: str) -> LoginResponse:
# 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,
}
)
token_data = {
"sub": str(user_id),
"role": role or "operator",
"username": username,
"display_name": display_name,
}
return LoginResponse(
access_token=token,
access_token=create_access_token(data=token_data),
refresh_token=create_refresh_token(data=token_data),
user=UserResponse(
id=str(user_id),
username=username,
@ -83,16 +89,60 @@ def login(username: str, password: str) -> LoginResponse:
db.close()
def refresh_access_token(refresh_token: str) -> dict:
"""
使用 Refresh Token 换取新的 Access Token。
校验:
1. Token 签名是否有效
2. Token type 是否为 "refresh"
3. Token 是否未过期
"""
try:
payload = decode_token(refresh_token)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh Token 无效或已过期,请重新登录",
)
# 校验 token 类型
if payload.get("type") != TOKEN_TYPE_REFRESH:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 Token 类型,仅接受 Refresh Token",
)
# 提取用户信息,签发新的 Access Token
access_token = create_access_token(
data={
"sub": payload.get("sub"),
"role": payload.get("role", "operator"),
"username": payload.get("username", ""),
"display_name": payload.get("display_name", ""),
}
)
return {"access_token": access_token, "token_type": "bearer"}
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""从 Bearer Token 解析当前用户(不查数据库,直接解 JWT)"""
"""从 Bearer Token 解析当前用户(仅接受 Access Token)"""
token = credentials.credentials
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
payload = decode_token(token)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="无效的 Token")
# 校验:仅接受 access token
if payload.get("type") == TOKEN_TYPE_REFRESH:
raise HTTPException(
status_code=401,
detail="请使用 Access Token 访问 API,Refresh Token 仅用于刷新",
)
return payload
except JWTError:
raise HTTPException(status_code=401, detail="无效的 Token")

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy import select, or_, cast, String
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@ -34,6 +34,7 @@ def _task_to_response(task: Task) -> TaskResponse:
status=task.status,
notify_parent_on_complete=task.notify_parent_on_complete,
is_rework=task.is_rework,
task_type=task.task_type,
remark=task.remark,
reject_reason=task.reject_reason,
received_at=task.received_at,
@ -277,15 +278,68 @@ async def update_overall_status(db: AsyncSession, serial_number: str, status_val
return await get_product_by_serial(db, serial_number)
async def get_all_products(db: AsyncSession, skip: int = 0, limit: int = 50) -> list[ProductResponse]:
"""获取产品列表"""
result = await db.execute(
select(Product)
.options(selectinload(Product.order))
.offset(skip)
.limit(limit)
.order_by(Product.created_at.desc())
)
async def get_all_products(
db: AsyncSession,
skip: int = 0,
limit: int = 50,
keyword: str | None = None,
status_filter: str | None = None,
) -> list[ProductResponse]:
"""
获取产品列表 — 支持多维 keyword 搜索 + 状态筛选
keyword: 同时模糊匹配 serial_number (产品身份证)、material_name/id (规格型号)、order_no (订单号)
status_filter: 按产品状态过滤 (如 PENDING / WIP / COMPLETED / ARCHIVED)
"""
stmt = select(Product).options(selectinload(Product.order))
# keyword 多字段 OR 模糊搜索
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
stmt = stmt.outerjoin(ProductionOrder, Product.order_id == ProductionOrder.id).where(
or_(
Product.serial_number.ilike(kw),
Product.material_name.ilike(kw),
cast(Product.material_id, String).ilike(kw),
Product.spec_model.ilike(kw),
ProductionOrder.order_no.ilike(kw),
)
).distinct()
# 状态筛选 — 大小写不敏感,支持组合过滤
if status_filter and status_filter.strip():
from sqlalchemy import func
sf = status_filter.strip().upper()
if sf == "DONE":
# "已完成" 匹配 COMPLETED 或 ARCHIVED
stmt = stmt.where(
or_(
func.upper(Product.status) == "COMPLETED",
func.upper(Product.status) == "ARCHIVED",
)
)
elif sf == "PENDING":
# "待流转" — 产品状态 PENDING 且所有顶层任务均未分配人
stmt = (
stmt.outerjoin(Task, Task.product_id == Product.id)
.where(func.upper(Product.status) == "PENDING")
.where(Task.assignee_id.is_(None))
.distinct()
)
elif sf == "PENDING_ASSIGNED":
# "待接收" — 产品状态 PENDING 但已有任务被分配(等待工人扫码)
stmt = (
stmt.outerjoin(Task, Task.product_id == Product.id)
.where(func.upper(Product.status) == "PENDING")
.where(Task.assignee_id.isnot(None))
.distinct()
)
else:
stmt = stmt.where(func.upper(Product.status) == sf)
stmt = stmt.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db.execute(stmt)
products = result.scalars().all()
return [
ProductResponse(

View File

@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
from app.core.time_utils import get_beijing_time
from app.models.product import Product
from app.models.task_log import TaskLog
@ -288,6 +289,12 @@ async def end_task(
"""
task = await _get_task_or_404(db, task_id)
# 校验:仅 SPAWN 协助分支可以结束,主分支(TRANSFER/RECOVERY)不能通过此接口终止
if not task.parent_task_id:
raise HTTPException(status_code=409, detail="根任务无法结束,请使用完工转交")
if task.task_type != "SPAWN":
raise HTTPException(status_code=409, detail="仅协助分支可以结束,主分支请使用完工转交")
# 校验:必须等待所有协助分支完成
await _check_children_done(db, task_id)
@ -562,7 +569,7 @@ async def reject_task(
task_name=task.task_name,
assignee_id=rework_assignee_id,
status=TASK_STATUS_PENDING,
task_type="TRANSFER",
task_type=task.task_type, # 🚀 继承被驳回任务的基因:主线→主线,协助→协助
notify_parent_on_complete=task.notify_parent_on_complete,
is_rework=True,
)
@ -576,6 +583,24 @@ async def reject_task(
remark=f"返工任务(驳回自「{task.task_name}」,原因: {request.reason}),分配给 {rework_assignee_id}",
)
# 🔔 通知:品质驳回
product_sn = ""
try:
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
p = product_result.scalar_one_or_none()
if p:
product_sn = p.serial_number or ""
except Exception:
pass
if rework_assignee_id:
db.add(Notification(
user_id=rework_assignee_id,
title="🔴 品质驳回提醒",
content=f"产品 [{product_sn}] 的「{task.task_name}」被驳回,原因: {request.reason}",
type=NOTIFY_REJECT,
task_id=rework_task.id,
))
await db.commit()
await db.refresh(task)
@ -697,6 +722,21 @@ async def transfer_task(
operator_id=operator_id,
remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}",
)
# 🔔 通知:新任务派发
if nt.assignee_id:
product_sn = ""
try:
if product:
product_sn = product.serial_number or ""
except Exception:
pass
db.add(Notification(
user_id=nt.assignee_id,
title=f"🟢 新任务派发",
content=f"产品 [{product_sn}] 的「{nt.task_name}」任务已分配给你",
type=NOTIFY_TRANSFER,
task_id=nt.id,
))
# --- 更新 Product 的 current_location_id ---
product_result = await db.execute(