feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息

- 在JWT令牌中添加用户角色信息
- 新增get_my_permissions接口用于获取当前用户权限码列表
- 重构认证回调逻辑,增加错误日志记录
- 更新用户信息获取接口使用Authorization头验证
```
This commit is contained in:
2026-04-06 00:40:08 +08:00
parent dfa8fa62a8
commit f1f8518985
71 changed files with 9402 additions and 191 deletions
+156
View File
@@ -0,0 +1,156 @@
"""审计日志 API"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.models.audit_log import AuditLog
from app.services.audit_service import query_logs
import csv
import io
router = APIRouter(prefix="/api/audit", tags=["审计日志"])
@router.get("/logs")
def get_audit_logs(
start_time: Optional[datetime] = Query(None),
end_time: Optional[datetime] = Query(None),
user_id: Optional[str] = Query(None),
username: Optional[str] = Query(None),
action_type: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""查询审计日志(仅管理员)"""
total, items = query_logs(
db,
start_time=start_time,
end_time=end_time,
user_id=user_id,
username=username,
action_type=action_type,
resource_type=resource_type,
status=status,
page=page,
page_size=page_size,
)
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [_fmt(r) for r in items],
}
@router.get("/logs/{log_id}")
def get_audit_log_detail(
log_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取单条审计日志详情"""
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
if not log:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="日志不存在")
return _fmt(log, detail=True)
@router.get("/stats")
def get_audit_stats(
days: int = Query(7, ge=1, le=90),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""审计日志统计(最近N天)"""
from datetime import timedelta
from sqlalchemy import func
since = datetime.utcnow() - timedelta(days=days)
rows = (
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
.filter(AuditLog.action_time >= since)
.group_by(AuditLog.action_type, AuditLog.status)
.all()
)
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
by_type = {}
for row in rows:
if row.action_type not in by_type:
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
by_type[row.action_type][row.status] = row.cnt
return {"total": total, "days": days, "by_type": by_type}
@router.get("/logs/export/csv")
def export_audit_logs(
start_time: Optional[datetime] = Query(None),
end_time: Optional[datetime] = Query(None),
action_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""导出审计日志为 CSV"""
_, items = query_logs(
db,
start_time=start_time,
end_time=end_time,
action_type=action_type,
status=status,
page=1,
page_size=5000,
)
def generate():
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["时间", "用户", "角色", "操作类型", "子类型", "路径", "状态码", "状态", "IP", "描述"])
for r in items:
writer.writerow([
r.action_time.strftime("%Y-%m-%d %H:%M:%S") if r.action_time else "",
r.username, r.user_role, r.action_type, r.action_subtype or "",
f"{r.request_method} {r.request_path}", r.status_code, r.status,
r.ip_address or "", r.description,
])
yield buf.getvalue().encode("utf-8-sig")
filename = f"audit_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv"
return StreamingResponse(
generate(),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
def _fmt(r: AuditLog, detail: bool = False) -> dict:
base = {
"id": r.id,
"action_time": r.action_time.isoformat() if r.action_time else None,
"user_id": r.user_id,
"username": r.username,
"user_role": r.user_role,
"action_type": r.action_type,
"action_subtype": r.action_subtype,
"request_method": r.request_method,
"request_path": r.request_path,
"status": r.status,
"status_code": r.status_code,
"resource_type": r.resource_type,
"resource_id": r.resource_id,
"resource_name": r.resource_name,
"description": r.description,
"ip_address": r.ip_address,
}
if detail:
base["request_params"] = r.request_params
base["response_data"] = r.response_data
base["error_message"] = r.error_message
base["user_agent"] = r.user_agent
return base
+36 -7
View File
@@ -1,12 +1,12 @@
"""认证 API"""
import base64
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.casdoor import casdoor_sdk
from app.core.security import create_access_token
from app.core.security import create_access_token, verify_token
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import Token, UserInfo
@@ -62,19 +62,48 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
user.last_login = datetime.utcnow()
db.commit()
jwt_token = create_access_token({"sub": str(user.id), "username": user.username})
jwt_token = create_access_token({
"sub": str(user.id),
"username": user.username,
"role": user.role or "user",
})
return {"access_token": jwt_token}
except HTTPException:
raise
except Exception as e:
import traceback
import logging
logging.getLogger(__name__).error("callback error: %s\n%s", e, traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@router.get("/profile", response_model=UserInfo)
def get_profile(token: str, db: Session = Depends(get_db)):
"""获取当前用户信息"""
from app.core.security import verify_token
@router.get("/permissions")
def get_my_permissions(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db)
):
"""获取当前用户的权限码列表"""
from app.middleware.permission_middleware import get_role_permissions
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="无效的令牌")
role = payload.get('role', 'user')
perms = get_role_permissions(role, db)
return {"role": role, "permissions": perms}
@router.get("/profile")
def get_profile(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db)
):
"""获取当前用户信息"""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="无效的令牌")
+16 -4
View File
@@ -10,6 +10,7 @@ from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
from app.services.check_service import CheckService
from app.middleware.permission_middleware import require_permission
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/check", tags=["状态检查"])
@@ -30,7 +31,7 @@ class CheckError(BaseModel):
@router.post("/status")
def trigger_check():
def trigger_check(_: dict = Depends(require_permission('device.check'))):
"""手动触发状态检查"""
try:
task = check_all_devices.delay()
@@ -41,7 +42,10 @@ def trigger_check():
@router.get("/status/{task_id}")
def get_check_status(task_id: str):
def get_check_status(
task_id: str,
_: dict = Depends(require_permission('device.check')),
):
"""查询状态检查任务进度和结果"""
task_result = AsyncResult(task_id, app=celery_app)
state = task_result.state
@@ -65,7 +69,11 @@ def get_check_status(task_id: str):
@router.post("/scan/{olt_id}")
def scan_olt(olt_id: int, db: Session = Depends(get_db)):
def scan_olt(
olt_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.check')),
):
"""扫描单台 OLT,预览发现的设备(不写入数据库)"""
try:
service = CheckService(db)
@@ -76,7 +84,11 @@ def scan_olt(olt_id: int, db: Session = Depends(get_db)):
@router.post("/discover/{olt_id}")
def discover_olt(olt_id: int, db: Session = Depends(get_db)):
def discover_olt(
olt_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.discover')),
):
"""扫描单台 OLT 并将新发现的 MAC 自动入库关联"""
try:
service = CheckService(db)
+163 -12
View File
@@ -5,7 +5,8 @@ from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice
from app.middleware.permission_middleware import require_permission
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
from app.schemas.device import DeviceListResponse, ONUDeviceResponse
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
@@ -18,8 +19,9 @@ def get_devices(
region: str = None,
school_name: str = None,
keyword: str = None,
status: str = None, # online / offline
db: Session = Depends(get_db)
status: str = None,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('device.view')),
):
"""获取设备列表"""
# 子查询:每台设备最新一条状态记录
@@ -44,6 +46,24 @@ def get_devices(
query = db.query(ONUDevice)
# 数据范围过滤:区域管理员只能看自己分配的区域,学校管理员只能看自己分配的学校
role = current.get('role', 'user')
if role == 'area_admin':
assigned = current.get('assigned_area') or ''
areas = [a.strip() for a in assigned.split(',') if a.strip()]
if areas:
query = query.filter(ONUDevice.region.in_(areas))
else:
# 未分配区域则看不到任何设备
query = query.filter(False)
elif role == 'school_admin':
assigned = current.get('assigned_school') or ''
schools = [s.strip() for s in assigned.split(',') if s.strip()]
if schools:
query = query.filter(ONUDevice.school_name.in_(schools))
else:
query = query.filter(False)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
@@ -141,17 +161,55 @@ def get_devices(
@router.get("/regions")
def get_regions(db: Session = Depends(get_db)):
"""获取所有区域列表"""
regions = db.query(distinct(ONUDevice.region)).filter(
def get_regions(
db: Session = Depends(get_db),
current: dict = Depends(require_permission('device.view')),
):
"""获取所有区域列表(受角色数据范围限制)"""
role = current.get('role', 'user')
query = db.query(distinct(ONUDevice.region)).filter(
ONUDevice.region.isnot(None),
ONUDevice.region != ''
).order_by(ONUDevice.region).all()
return [r[0] for r in regions]
)
if role == 'area_admin':
assigned = current.get('assigned_area') or ''
areas = [a.strip() for a in assigned.split(',') if a.strip()]
if areas:
query = query.filter(ONUDevice.region.in_(areas))
else:
return []
elif role == 'school_admin':
assigned = current.get('assigned_school') or ''
schools = [s.strip() for s in assigned.split(',') if s.strip()]
if schools:
query = query.filter(ONUDevice.school_name.in_(schools))
else:
return []
return [r[0] for r in query.order_by(ONUDevice.region).all()]
@router.get("/schools")
def get_schools(
region: str = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取所有学校列表(可按区域筛选)"""
query = db.query(distinct(ONUDevice.school_name)).filter(
ONUDevice.school_name.isnot(None),
ONUDevice.school_name != ''
)
if region:
query = query.filter(ONUDevice.region == region)
return [r[0] for r in query.order_by(ONUDevice.school_name).all()]
@router.get("/{device_id}", response_model=ONUDeviceResponse)
def get_device(device_id: int, db: Session = Depends(get_db)):
def get_device(
device_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取设备详情"""
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
@@ -186,7 +244,11 @@ def get_device(device_id: int, db: Session = Depends(get_db)):
@router.post("/{device_id}/refresh")
def refresh_device_status(device_id: int, db: Session = Depends(get_db)):
def refresh_device_status(
device_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.check')),
):
"""通过 SSH 单独更新一台设备的状态和距离"""
from app.services.check_service import CheckService
try:
@@ -206,8 +268,16 @@ class DeviceUpdate(BaseModel):
notes: Optional[str] = None
class DeviceReplaceRequest(BaseModel):
new_mac: str
reason: Optional[str] = None
@router.delete("/status/all")
def clear_all_status(db: Session = Depends(get_db)):
def clear_all_status(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.delete')),
):
"""清空所有设备状态历史记录"""
db.query(DeviceStatusHistory).delete()
db.commit()
@@ -215,7 +285,12 @@ def clear_all_status(db: Session = Depends(get_db)):
@router.put("/{device_id}")
def update_device(device_id: int, body: DeviceUpdate, db: Session = Depends(get_db)):
def update_device(
device_id: int,
body: DeviceUpdate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.edit')),
):
"""更新设备信息(区域、学校、楼宇、房间号、备注)"""
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
@@ -229,3 +304,79 @@ def update_device(device_id: int, body: DeviceUpdate, db: Session = Depends(get_
return {"message": "更新成功"}
@router.post("/{device_id}/replace")
def replace_device(
device_id: int,
body: DeviceReplaceRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('device.edit')),
):
"""更换设备 MAC 地址,并记录更换历史"""
from datetime import datetime
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
new_mac = body.new_mac.upper().strip()
# 校验 MAC 格式(允许 XX:XX:XX:XX:XX:XX 或 XXXXXXXXXXXX
import re
if not re.match(r'^([0-9A-F]{2}[:-]){5}[0-9A-F]{2}$|^[0-9A-F]{12}$', new_mac):
raise HTTPException(status_code=400, detail="MAC 地址格式不正确")
# 检查新 MAC 是否已被其他设备使用
existing = db.query(ONUDevice).filter(
ONUDevice.mac_address == new_mac,
ONUDevice.id != device_id
).first()
if existing:
raise HTTPException(status_code=400, detail="该 MAC 地址已被其他设备使用")
# 同步更新库存序列号设备的 onu_device_id 关联(如有)
from app.models.inventory import SerialDevice
old_serial = db.query(SerialDevice).filter(SerialDevice.onu_device_id == device_id).first()
if old_serial:
old_serial.onu_device_id = None
old_serial.status = "returned"
new_serial = db.query(SerialDevice).filter(SerialDevice.mac_address == new_mac).first()
if new_serial:
new_serial.onu_device_id = device_id
new_serial.status = "in_use"
record = DeviceReplacement(
onu_device_id=device_id,
old_mac=device.mac_address,
new_mac=new_mac,
reason=body.reason or None,
operator_id=current.get("sub", ""),
operator_name=current.get("username", ""),
replaced_at=datetime.utcnow(),
)
db.add(record)
device.mac_address = new_mac
db.commit()
return {"message": "更换成功", "old_mac": record.old_mac, "new_mac": new_mac}
@router.get("/{device_id}/replacements")
def get_device_replacements(
device_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取设备更换历史"""
records = db.query(DeviceReplacement).filter(
DeviceReplacement.onu_device_id == device_id
).order_by(DeviceReplacement.replaced_at.desc()).all()
return [
{
"id": r.id,
"old_mac": r.old_mac,
"new_mac": r.new_mac,
"reason": r.reason,
"operator_name": r.operator_name,
"replaced_at": r.replaced_at,
}
for r in records
]
+4 -2
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, UploadFile, File, Depends, Response
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.services.import_service import ImportService
import shutil
import io
@@ -11,7 +12,7 @@ router = APIRouter(prefix="/api/import", tags=["数据导入"])
@router.get("/template")
def download_template():
def download_template(_: dict = Depends(require_permission('device.import'))):
"""下载导入数据模板"""
wb = Workbook()
ws = wb.active
@@ -54,7 +55,8 @@ def download_template():
async def upload_excel(
file: UploadFile = File(...),
olt_id: int = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.import')),
):
"""上传并导入 Excel 文件(仅导入 MAC 信息,不关联 OLT)"""
file_path = f"/tmp/{file.filename}"
+196
View File
@@ -0,0 +1,196 @@
"""库存管理 API"""
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.schemas.inventory import (
CategoryCreate, CategoryResponse,
MaterialCreate, MaterialUpdate, MaterialListResponse,
PurchaseInRequest, AllocateOutRequest, ReturnInRequest,
TransactionListResponse, SerialDeviceListResponse,
CheckCreate, CheckListResponse, InventorySummary,
)
import app.services.inventory_service as svc
router = APIRouter(prefix="/api/inventory", tags=["库存管理"])
# ── 物料分类 ──────────────────────────────────────────────────────────────────
@router.get("/categories")
def list_categories(
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_categories(db)
@router.post("/categories", response_model=CategoryResponse)
def create_category(
body: CategoryCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.manage")),
):
return svc.create_category(db, body.name, body.code, body.description)
# ── 物料 ──────────────────────────────────────────────────────────────────────
@router.get("/materials", response_model=MaterialListResponse)
def list_materials(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
category_id: Optional[int] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_materials(db, skip, limit, category_id, keyword)
@router.post("/materials")
def create_material(
body: MaterialCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.manage")),
):
return svc.create_material(db, body.model_dump())
@router.put("/materials/{material_id}")
def update_material(
material_id: int,
body: MaterialUpdate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.manage")),
):
return svc.update_material(db, material_id, body.model_dump(exclude_none=True))
@router.delete("/materials/{material_id}")
def delete_material(
material_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.manage")),
):
svc.delete_material(db, material_id)
return {"message": "删除成功"}
# ── 出入库操作 ────────────────────────────────────────────────────────────────
@router.post("/transactions/purchase")
def purchase_in(
body: PurchaseInRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.transaction")),
):
return svc.purchase_in(db, body.model_dump(), int(current.get("sub", 0)))
@router.post("/transactions/allocate")
def allocate_out(
body: AllocateOutRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.transaction")),
):
return svc.allocate_out(db, body.model_dump(), int(current.get("sub", 0)))
@router.post("/transactions/return")
def return_in(
body: ReturnInRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.transaction")),
):
return svc.return_in(db, body.serial_device_id, body.return_type, body.notes, int(current.get("sub", 0)))
@router.get("/transactions", response_model=TransactionListResponse)
def list_transactions(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
transaction_type: Optional[str] = None,
material_id: Optional[int] = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_transactions(db, skip, limit, transaction_type, material_id)
@router.get("/transactions/{transaction_id}")
def get_transaction(
transaction_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_transaction_detail(db, transaction_id)
@router.get("/batches")
def list_batches(
material_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_batches_by_material(db, material_id)
# ── 序列号设备 ────────────────────────────────────────────────────────────────
@router.get("/serial-devices", response_model=SerialDeviceListResponse)
def list_serial_devices(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
material_id: Optional[int] = None,
status: Optional[str] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_serial_devices(db, skip, limit, material_id, status, keyword)
# ── 库存统计 ──────────────────────────────────────────────────────────────────
@router.get("/summary", response_model=InventorySummary)
def get_summary(
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_summary(db)
# ── 盘点 ──────────────────────────────────────────────────────────────────────
@router.get("/checks", response_model=CheckListResponse)
def list_checks(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
material_id: Optional[int] = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission("inventory.view")),
):
return svc.get_checks(db, skip, limit, material_id)
@router.post("/checks")
def create_check(
body: CheckCreate,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.check")),
):
return svc.create_check(db, body.model_dump(), int(current.get("sub", 0)))
@router.post("/checks/{check_id}/adjust")
def adjust_check(
check_id: int,
db: Session = Depends(get_db),
current: dict = Depends(require_permission("inventory.check")),
):
return svc.adjust_check(db, check_id, int(current.get("sub", 0)))
+118 -18
View File
@@ -1,8 +1,10 @@
"""OLT 设备管理 API"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import distinct
from pydantic import BaseModel
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.models.device import OLTDevice
import pandas as pd
import io
@@ -15,6 +17,7 @@ class OLTCreate(BaseModel):
username: str
password: str
slot_command: str = "display onu slot"
region: str = "城区"
location: str = ""
description: str = ""
@@ -23,17 +26,57 @@ class OLTEdit(BaseModel):
username: str
password: str = None
slot_command: str = "display onu slot"
region: str = "城区"
location: str = ""
description: str = ""
@router.get("/regions")
def get_olt_regions(
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.view')),
):
"""获取 OLT 设备的所有区域(受角色数据范围限制)"""
query = db.query(distinct(OLTDevice.region)).filter(
OLTDevice.region.isnot(None),
OLTDevice.region != ''
)
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if areas:
query = query.filter(OLTDevice.region.in_(areas))
else:
return []
return sorted([r[0] for r in query.all()])
@router.get("/devices")
def get_devices(db: Session = Depends(get_db)):
return db.query(OLTDevice).all()
def get_devices(
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.view')),
):
q = db.query(OLTDevice)
# 区域管理员只能看自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if areas:
q = q.filter(OLTDevice.region.in_(areas))
else:
return []
return q.all()
@router.post("/devices")
def create_device(device: OLTCreate, db: Session = Depends(get_db)):
def create_device(
device: OLTCreate,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.manage')),
):
# 区域管理员只能创建自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
db_device = OLTDevice(**device.dict())
db.add(db_device)
db.commit()
@@ -41,15 +84,26 @@ def create_device(device: OLTCreate, db: Session = Depends(get_db)):
@router.put("/devices/{ip_address}")
def update_device(ip_address: str, device: OLTEdit, db: Session = Depends(get_db)):
def update_device(
ip_address: str,
device: OLTEdit,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.manage')),
):
db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not db_device:
raise HTTPException(status_code=404, detail="设备不存在")
# 区域管理员只能管理自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if db_device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
db_device.username = device.username
if device.password:
db_device.password = device.password
db_device.slot_command = device.slot_command
db_device.region = device.region
db_device.location = device.location
db_device.description = device.description
@@ -58,14 +112,21 @@ def update_device(ip_address: str, device: OLTEdit, db: Session = Depends(get_db
@router.delete("/devices/{ip_address}")
def delete_device(ip_address: str, db: Session = Depends(get_db)):
def delete_device(
ip_address: str,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.manage')),
):
from app.models.device import ONUDevice
device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
# 检查是否有关联的 ONU 设备
onu_count = db.query(ONUDevice).filter(ONUDevice.olt_id == device.id).count()
if onu_count > 0:
raise HTTPException(status_code=400, detail=f"该 OLT 设备下还有 {onu_count} 个 ONU 设备,无法删除")
@@ -76,7 +137,11 @@ def delete_device(ip_address: str, db: Session = Depends(get_db)):
@router.post("/import")
async def import_devices(file: UploadFile = File(...), db: Session = Depends(get_db)):
async def import_devices(
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
try:
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
@@ -118,7 +183,7 @@ async def import_devices(file: UploadFile = File(...), db: Session = Depends(get
@router.get("/template")
def download_template():
def download_template(_: dict = Depends(require_permission('olt.manage'))):
from fastapi.responses import FileResponse
return FileResponse(
path="/home/v6ole/pyproject/H3ConuMS2/backend/templates/OLT设备导入模板.xlsx",
@@ -127,7 +192,11 @@ def download_template():
@router.get("/duplicate-macs")
def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)):
def get_duplicate_macs(
olt_id: int = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.view')),
):
"""查询重复 MAC 地址记录"""
from app.models.device import DuplicateMac
query = db.query(DuplicateMac)
@@ -148,7 +217,11 @@ def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)):
@router.delete("/duplicate-macs/{record_id}")
def delete_duplicate_mac(record_id: int, db: Session = Depends(get_db)):
def delete_duplicate_mac(
record_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
"""删除重复 MAC 记录(已处理后清除)"""
from app.models.device import DuplicateMac
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
@@ -164,7 +237,12 @@ class ClearPortRequest(BaseModel):
@router.post("/duplicate-macs/{record_id}/clear-port")
def clear_onu_port(record_id: int, body: ClearPortRequest, db: Session = Depends(get_db)):
def clear_onu_port(
record_id: int,
body: ClearPortRequest,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
"""通过 SSH 清除指定端口的 ONU 配置,并从 ports 列表中移除该端口"""
from app.models.device import DuplicateMac
from app.services.ssh_service import SSHService
@@ -204,7 +282,10 @@ def clear_onu_port(record_id: int, body: ClearPortRequest, db: Session = Depends
@router.get("/new-devices")
def get_new_devices(db: Session = Depends(get_db)):
def get_new_devices(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.view')),
):
"""查询新发现的设备列表(待补全信息)"""
from app.models.device import NewDevice, ONUDevice
rows = (
@@ -241,7 +322,12 @@ class NewDeviceUpdate(BaseModel):
@router.put("/new-devices/{record_id}")
def update_new_device(record_id: int, body: NewDeviceUpdate, db: Session = Depends(get_db)):
def update_new_device(
record_id: int,
body: NewDeviceUpdate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
"""补全新设备信息,完成后从 new_devices 移除"""
from app.models.device import NewDevice, ONUDevice
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
@@ -265,7 +351,11 @@ def update_new_device(record_id: int, body: NewDeviceUpdate, db: Session = Depen
@router.delete("/new-devices/{record_id}")
def dismiss_new_device(record_id: int, db: Session = Depends(get_db)):
def dismiss_new_device(
record_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.manage')),
):
"""忽略新设备(不补全信息,仅从待处理列表移除)"""
from app.models.device import NewDevice
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
@@ -277,7 +367,10 @@ def dismiss_new_device(record_id: int, db: Session = Depends(get_db)):
@router.post("/quick-scan")
def quick_scan(db: Session = Depends(get_db)):
def quick_scan(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.discover')),
):
"""多线程对所有 OLT 同时执行扫描,更新已有设备状态"""
from app.services.check_service import CheckService
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -339,7 +432,10 @@ def quick_scan(db: Session = Depends(get_db)):
@router.post("/loopback-detection")
def loopback_detection(db: Session = Depends(get_db)):
def loopback_detection(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.loopback')),
):
"""对所有 OLT 并发执行环路检测,返回有环路的端口及对应设备信息"""
from app.models.device import ONUDevice
from app.services.ssh_service import SSHService
@@ -407,7 +503,11 @@ class TogglePortRequest(BaseModel):
@router.get("/devices/{olt_id}/ports")
def get_olt_ports(olt_id: int, db: Session = Depends(get_db)):
def get_olt_ports(
olt_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.port_manage')),
):
"""获取指定 OLT 的所有 Olt 端口状态"""
from app.services.ssh_service import SSHService
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
@@ -425,7 +525,7 @@ def get_olt_ports(olt_id: int, db: Session = Depends(get_db)):
@router.post("/devices/{olt_id}/ports/toggle")
def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db)):
def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db), _: dict = Depends(require_permission('olt.port_manage'))):
"""开启或关闭指定 OLT 端口"""
from app.services.ssh_service import SSHService
if body.action not in ("shutdown", "undo shutdown"):
+5 -2
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.services.provision_service import ProvisionService
router = APIRouter(prefix="/api/provision", tags=["业务下发"])
@@ -29,7 +30,8 @@ class ProvisionResponse(BaseModel):
@router.post("/service", response_model=ProvisionResponse)
def provision_single_device(
request: ProvisionRequest,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.edit')),
):
"""下发业务到单个设备"""
service = ProvisionService(db)
@@ -51,7 +53,8 @@ def provision_single_device(
@router.post("/batch", response_model=dict)
def provision_batch_devices(
request: BatchProvisionRequest,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.edit')),
):
"""批量下发业务"""
service = ProvisionService(db)
+110
View File
@@ -0,0 +1,110 @@
"""角色权限配置 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from pydantic import BaseModel
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission, invalidate_role_cache
from app.models.permission import Permission
router = APIRouter(prefix="/api", tags=["角色权限"])
VALID_ROLES = ['admin', 'area_admin', 'school_admin', 'user']
class RolePermissionsUpdate(BaseModel):
permissions: list[str] # 权限码列表
@router.get("/permissions")
def get_all_permissions(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('user.view')),
):
"""获取所有权限码列表(按模块分组)"""
perms = db.query(Permission).order_by(Permission.module, Permission.code).all()
result = {}
for p in perms:
module = p.module or 'other'
if module not in result:
result[module] = []
result[module].append({
"id": p.id,
"name": p.name,
"code": p.code,
"description": p.description,
})
return result
@router.get("/roles")
def get_roles(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('user.view')),
):
"""获取所有角色及其当前权限"""
rows = db.execute(
text("""
SELECT rp.role, p.code
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
ORDER BY rp.role, p.code
""")
).fetchall()
role_map: dict[str, list[str]] = {r: [] for r in VALID_ROLES}
for role, code in rows:
if role in role_map:
role_map[role].append(code)
# admin 特殊处理
role_map['admin'] = ['*']
return [
{"role": role, "permissions": perms}
for role, perms in role_map.items()
]
@router.put("/roles/{role}/permissions")
def update_role_permissions(
role: str,
body: RolePermissionsUpdate,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('user.manage')),
):
"""更新角色权限(替换全量)"""
if role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"无效角色,可选:{', '.join(VALID_ROLES)}")
if role == 'admin':
raise HTTPException(status_code=400, detail="admin 角色权限不可修改")
# 验证权限码是否存在
if body.permissions:
existing = {p.code for p in db.query(Permission).filter(
Permission.code.in_(body.permissions)
).all()}
invalid = set(body.permissions) - existing
if invalid:
raise HTTPException(status_code=400, detail=f"无效权限码:{', '.join(invalid)}")
# 删除旧权限,插入新权限
db.execute(text("DELETE FROM role_permissions WHERE role = :role"), {"role": role})
if body.permissions:
# 查出权限 id 再插入,避免 ANY 语法兼容问题
perm_ids = db.execute(
text("SELECT id FROM permissions WHERE code IN :codes"),
{"codes": tuple(body.permissions)}
).fetchall()
for (pid,) in perm_ids:
db.execute(
text("INSERT INTO role_permissions (role, permission_id) VALUES (:role, :pid)"),
{"role": role, "pid": pid}
)
db.commit()
# 清除 Redis 缓存
invalidate_role_cache(role)
return {"message": "权限更新成功"}
+76
View File
@@ -0,0 +1,76 @@
"""系统设置 API(仅管理员)"""
import time
import redis as redis_lib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.config import settings
from app.middleware.permission_middleware import require_permission
from app.models.setting import SystemSetting
router = APIRouter(prefix="/api/settings", tags=["系统设置"])
MIN_CHECK_INTERVAL = 300 # 5 分钟
MAX_CHECK_INTERVAL = 86400 # 24 小时
_INTERVAL_REDIS_KEY = "system:check_interval_seconds"
def _get_redis():
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
@router.get("")
def get_settings(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取所有系统设置,附带下次扫描时间"""
rows = db.query(SystemSetting).all()
result = {row.key: {"value": row.value, "description": row.description} for row in rows}
# 计算下次扫描时间
try:
r = _get_redis()
interval_str = r.get(_INTERVAL_REDIS_KEY)
last_run_str = r.get("check_all_devices:last_run")
is_running = bool(r.get("check_all_devices:running"))
interval = int(interval_str) if interval_str else 1800
next_run_ts = (float(last_run_str) + interval) if last_run_str else None
result["next_check_at"] = {
"value": str(int(next_run_ts)) if next_run_ts else None,
"running": is_running,
"description": "下次扫描时间戳(Unix"
}
except Exception:
result["next_check_at"] = {"value": None, "running": False, "description": "下次扫描时间戳(Unix"}
return result
@router.put("/check_interval")
def update_check_interval(
seconds: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""更新定时检查间隔(秒),范围 300~86400"""
if seconds < MIN_CHECK_INTERVAL:
raise HTTPException(status_code=400, detail=f"间隔不能小于 {MIN_CHECK_INTERVAL} 秒(5分钟)")
if seconds > MAX_CHECK_INTERVAL:
raise HTTPException(status_code=400, detail=f"间隔不能大于 {MAX_CHECK_INTERVAL} 秒(24小时)")
setting = db.query(SystemSetting).filter_by(key='check_interval_seconds').first()
if setting:
setting.value = str(seconds)
else:
db.add(SystemSetting(key='check_interval_seconds', value=str(seconds), description='定时检查间隔(秒)'))
db.commit()
# 同步到 Redis,让 Celery 任务立即生效
_get_redis().set(_INTERVAL_REDIS_KEY, str(seconds))
# 重置上次运行时间,让下次触发时立即按新间隔计算
_get_redis().delete("check_all_devices:last_run")
return {"key": "check_interval_seconds", "value": seconds}
+146 -16
View File
@@ -3,14 +3,18 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func, case
from app.core.database import get_db
from app.models.device import ONUDevice, DeviceStatusHistory
from datetime import datetime, timedelta
from app.middleware.permission_middleware import require_permission
from app.models.device import ONUDevice, DeviceStatusHistory, DeviceDailySnapshot
from datetime import datetime, timedelta, date
router = APIRouter(prefix="/api/stats", tags=["统计"])
@router.get("/dashboard")
def get_dashboard(db: Session = Depends(get_db)):
def get_dashboard(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""仪表板统计:总体、城区、城郊、乡镇在线率"""
# 每台设备最新状态子查询
latest_subq = (
@@ -92,7 +96,10 @@ def get_dashboard(db: Session = Depends(get_db)):
@router.get("/summary")
def get_summary(db: Session = Depends(get_db)):
def get_summary(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取统计摘要"""
total = db.query(ONUDevice).count()
latest_status = db.query(
@@ -103,16 +110,139 @@ def get_summary(db: Session = Depends(get_db)):
return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)}
@router.get("/trend")
def get_trend(days: int = 7, db: Session = Depends(get_db)):
"""获取状态趋势数据"""
start_date = datetime.utcnow() - timedelta(days=days)
history = db.query(
func.date(DeviceStatusHistory.checked_at).label('date'),
func.sum(func.case((DeviceStatusHistory.status == 'online', 1), else_=0)).label('online'),
func.sum(func.case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label('offline')
).filter(DeviceStatusHistory.checked_at >= start_date).group_by(
func.date(DeviceStatusHistory.checked_at)
).all()
return [{"date": str(h.date), "online": h.online, "offline": h.offline} for h in history]
@router.get("/by-region")
def get_by_region(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""各区域设备数量及在线率(用于饼图)"""
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_status_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
DeviceStatusHistory.status
)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.subquery()
)
rows = (
db.query(
ONUDevice.region,
func.count().label("total"),
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
)
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
.group_by(ONUDevice.region)
.order_by(func.count().desc())
.all()
)
return [
{
"region": row.region or "未知",
"total": int(row.total or 0),
"online": int(row.online or 0),
"offline": int(row.offline or 0),
}
for row in rows
]
@router.get("/trend")
def get_trend(
days: int = 7,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取状态趋势数据(优先查快照表,不足时实时聚合)"""
today = date.today()
date_range = [(today - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(days - 1, -1, -1)]
# 查快照表(不含今天,今天用实时数据)
snapshots = (
db.query(DeviceDailySnapshot)
.filter(DeviceDailySnapshot.snapshot_date.in_(date_range[:-1]))
.all()
)
snapshot_map = {s.snapshot_date: s for s in snapshots}
# 今天实时聚合
today_str = today.strftime('%Y-%m-%d')
start_of_today = datetime.combine(today, datetime.min.time())
daily_latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
)
.filter(DeviceStatusHistory.checked_at >= start_of_today)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
today_row = (
db.query(
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
)
.join(
daily_latest_subq,
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
)
.one()
)
result = []
for d in date_range:
if d == today_str:
result.append({
"date": d,
"online": int(today_row.online or 0),
"offline": int(today_row.offline or 0),
})
elif d in snapshot_map:
s = snapshot_map[d]
result.append({"date": d, "online": s.online, "offline": s.offline})
else:
# 快照缺失时实时聚合该天数据
day = datetime.strptime(d, '%Y-%m-%d').date()
day_start = datetime.combine(day, datetime.min.time())
day_end = datetime.combine(day, datetime.max.time())
subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
)
.filter(DeviceStatusHistory.checked_at.between(day_start, day_end))
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
row = (
db.query(
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
)
.join(
subq,
(DeviceStatusHistory.onu_device_id == subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == subq.c.max_checked_at)
)
.one()
)
result.append({"date": d, "online": int(row.online or 0), "offline": int(row.offline or 0)})
return result
+81
View File
@@ -0,0 +1,81 @@
"""用户管理 API"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import asc
from app.core.database import get_db
from app.middleware.permission_middleware import require_permission
from app.models.user import User
from app.schemas.user import UserListResponse, UserListItem, UserRoleUpdate
router = APIRouter(prefix="/api/users", tags=["用户管理"])
VALID_ROLES = {'admin', 'area_admin', 'school_admin', 'user'}
@router.get("", response_model=UserListResponse)
def get_users(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
role: str = None,
keyword: str = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('user.view')),
):
"""获取用户列表"""
query = db.query(User)
if role:
query = query.filter(User.role == role)
if keyword:
query = query.filter(
User.username.contains(keyword) | User.email.contains(keyword)
)
query = query.order_by(asc(User.created_at))
total = query.count()
items = query.offset(skip).limit(limit).all()
return {"total": total, "items": items}
@router.put("/{user_id}/role")
def update_user_role(
user_id: int,
body: UserRoleUpdate,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('user.manage')),
):
"""修改用户角色及分配区域/学校"""
if body.role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"无效角色,可选:{', '.join(VALID_ROLES)}")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 不允许修改自己的角色
if str(user.id) == current.get('sub'):
raise HTTPException(status_code=400, detail="不能修改自己的角色")
user.role = body.role
user.assigned_area = body.assigned_area
user.assigned_school = body.assigned_school
db.commit()
return {"message": "更新成功"}
@router.put("/{user_id}/toggle")
def toggle_user(
user_id: int,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('user.manage')),
):
"""启用/禁用用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if str(user.id) == current.get('sub'):
raise HTTPException(status_code=400, detail="不能禁用自己")
user.is_active = not user.is_active
db.commit()
return {"message": "已禁用" if not user.is_active else "已启用", "is_active": user.is_active}
+18 -3
View File
@@ -1,5 +1,6 @@
"""Celery 配置"""
from celery import Celery
from celery.schedules import crontab
from app.core.config import settings
celery_app = Celery(
@@ -12,12 +13,26 @@ celery_app.conf.update(
task_serializer='json',
result_serializer='json',
accept_content=['json'],
timezone='UTC',
timezone='Asia/Shanghai',
enable_utc=True,
# 使用专属队列,避免与同 Redis 上的其他 Celery 项目抢任务
task_default_queue='h3c_onu_ms',
beat_schedule={
'check-devices-every-30-minutes': {
# 每5分钟触发一次(最小间隔),任务内部根据配置的间隔自行节流
'check-devices-scheduler': {
'task': 'app.tasks.check_tasks.check_all_devices',
'schedule': settings.CHECK_INTERVAL,
'schedule': 300,
'options': {'queue': 'h3c_onu_ms'},
},
'aggregate-daily-snapshot': {
'task': 'app.tasks.check_tasks.aggregate_daily_snapshot',
'schedule': crontab(hour=1, minute=0), # 每天凌晨 1:00
'options': {'queue': 'h3c_onu_ms'},
},
'cleanup-audit-logs': {
'task': 'app.tasks.audit_tasks.cleanup_audit_logs_task',
'schedule': crontab(hour=2, minute=0), # 每天凌晨 2:00
'options': {'queue': 'h3c_onu_ms'},
},
},
)
+8 -1
View File
@@ -2,7 +2,8 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit
from app.middleware.audit_middleware import AuditMiddleware
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
@@ -13,6 +14,7 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(AuditMiddleware)
app.include_router(auth.router)
app.include_router(devices.router)
@@ -21,6 +23,11 @@ app.include_router(import_data.router)
app.include_router(stats.router)
app.include_router(olt.router)
app.include_router(provision.router)
app.include_router(users.router)
app.include_router(roles.router)
app.include_router(inventory.router)
app.include_router(settings_api.router)
app.include_router(audit.router)
@app.get("/health")
+109
View File
@@ -0,0 +1,109 @@
"""审计日志中间件:拦截所有 API 请求,异步写入审计日志"""
import json
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.core.security import verify_token
# 不记录审计日志的路径前缀
_SKIP_PATHS = {
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/auth/login", # 仅获取登录 URL,无用户身份
"/api/auth/permissions", # 高频只读
"/api/stats/",
"/api/olt/regions",
"/api/olt/new-devices",
"/api/olt/duplicate-macs",
}
# 只记录写操作 + 登录回调 + 特定查询(GET 默认跳过,以下 GET 例外)
_ALWAYS_LOG_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
_LOG_GET_PATHS = {
"/api/auth/profile",
}
def _should_log(method: str, path: str) -> bool:
for skip in _SKIP_PATHS:
if path.startswith(skip):
return False
if method in _ALWAYS_LOG_METHODS:
return True
if method == "GET":
return path in _LOG_GET_PATHS
return False
def _extract_token_payload(request: Request) -> dict:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
payload = verify_token(auth[7:])
if payload:
return payload
return {}
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
method = request.method
path = request.url.path
if not _should_log(method, path):
return await call_next(request)
# 读取请求体(只读一次,需要重新构造)
request_params = None
try:
body_bytes = await request.body()
if body_bytes:
try:
request_params = json.loads(body_bytes)
# 脱敏:移除密码字段
if isinstance(request_params, dict):
for k in ("password", "passwd", "secret"):
if k in request_params:
request_params[k] = "***"
except Exception:
pass
except Exception:
pass
start_time = time.time()
response = await call_next(request)
duration_ms = int((time.time() - start_time) * 1000)
# 异步写日志(不等待)
try:
payload = _extract_token_payload(request)
user_id = payload.get("sub", "anonymous")
username = payload.get("username", "anonymous")
user_role = payload.get("role", "")
ip_address = ""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
ip_address = forwarded.split(",")[0].strip()
elif request.client:
ip_address = request.client.host
from app.tasks.audit_tasks import create_audit_log_task
create_audit_log_task.delay(
user_id=str(user_id),
username=username,
user_role=user_role,
method=method,
path=path,
ip_address=ip_address,
user_agent=request.headers.get("user-agent", "")[:500],
status_code=response.status_code,
request_params=request_params,
response_data=None, # 不捕获响应体(性能考虑)
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
)
except Exception:
pass # 中间件异常绝不影响主响应
return response
+85 -16
View File
@@ -1,27 +1,96 @@
"""权限检查中间件"""
from fastapi import HTTPException, Depends
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
import json
from fastapi import HTTPException, Depends, Header
from sqlalchemy.orm import Session
from sqlalchemy import text
import redis
from app.core.database import get_db
from app.core.security import verify_token
from app.core.config import settings
ROLE_PERMISSIONS = {
'admin': ['*'],
'area_admin': ['device.view', 'device.check'],
'school_admin': ['device.view'],
'user': ['device.view']
}
_redis_client = None
def check_permission(required_permission: str):
def permission_checker(token: str):
def get_redis() -> redis.Redis:
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_client
def get_role_permissions(role: str, db: Session) -> list:
"""从数据库加载角色权限,结果缓存到 Redis(TTL 5分钟)"""
if role == 'admin':
return ['*']
r = get_redis()
cache_key = f"permissions:role:{role}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
rows = db.execute(
text("""
SELECT p.code FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role = :role
"""),
{"role": role}
).fetchall()
perms = [row[0] for row in rows]
r.setex(cache_key, 300, json.dumps(perms))
return perms
def invalidate_role_cache(role: str) -> None:
"""修改角色权限后清除缓存"""
get_redis().delete(f"permissions:role:{role}")
def require_permission(permission: str):
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
def dependency(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db),
) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="未授权")
raise HTTPException(status_code=401, detail="令牌无效或已过期")
role = payload.get('role', 'user')
permissions = ROLE_PERMISSIONS.get(role, [])
perms = get_role_permissions(role, db)
if '*' in permissions or required_permission in permissions:
return payload
if '*' not in perms and permission not in perms:
raise HTTPException(status_code=403, detail="权限不足")
raise HTTPException(status_code=403, detail="权限不足")
# 附加用户的区域/学校分配信息,供数据范围过滤使用
user_id = payload.get('sub')
if user_id and role in ('area_admin', 'school_admin'):
from app.models.user import User
user = db.query(User).filter(User.id == int(user_id)).first()
if user:
payload['assigned_area'] = user.assigned_area
payload['assigned_school'] = user.assigned_school
return permission_checker
return payload
return dependency
def get_current_user(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db),
) -> dict:
"""仅验证登录状态,不检查具体权限"""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="令牌无效或已过期")
return payload
+52
View File
@@ -0,0 +1,52 @@
"""审计日志数据库模型"""
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
# 用户信息
user_id = Column(String(100), nullable=False)
username = Column(String(100), nullable=False)
user_role = Column(String(50))
# 操作信息
action_time = Column(TIMESTAMP, nullable=False, server_default=func.now())
action_type = Column(String(50), nullable=False) # auth/device/olt/user/system/inventory
action_subtype = Column(String(50)) # create/update/delete/login/...
# 请求信息
ip_address = Column(String(45))
user_agent = Column(Text)
request_method = Column(String(10))
request_path = Column(String(500))
# 操作结果
status = Column(String(20), nullable=False) # success/failed/error
status_code = Column(Integer)
# 资源信息
resource_type = Column(String(50))
resource_id = Column(String(100))
resource_name = Column(String(200))
# 日志内容
description = Column(Text, nullable=False)
request_params = Column(JSONB)
response_data = Column(JSONB)
error_message = Column(Text)
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
__table_args__ = (
Index("idx_audit_logs_action_time", "action_time"),
Index("idx_audit_logs_user_id", "user_id"),
Index("idx_audit_logs_action_type", "action_type"),
Index("idx_audit_logs_resource_type", "resource_type"),
Index("idx_audit_logs_status", "status"),
)
+28
View File
@@ -13,6 +13,7 @@ class OLTDevice(Base):
username = Column(String(100), nullable=False)
password = Column(Text, nullable=False)
slot_command = Column(String(50), nullable=False)
region = Column(String(100), index=True)
location = Column(String(200))
description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
@@ -72,6 +73,18 @@ class DuplicateMac(Base):
last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
class DeviceDailySnapshot(Base):
"""设备每日状态快照(用于趋势图,避免全量扫描历史表)"""
__tablename__ = "device_daily_snapshots"
id = Column(BigInteger, primary_key=True, index=True)
snapshot_date = Column(String(10), nullable=False, index=True) # YYYY-MM-DD
total = Column(Integer, nullable=False, default=0)
online = Column(Integer, nullable=False, default=0)
offline = Column(Integer, nullable=False, default=0)
created_at = Column(TIMESTAMP, server_default=func.now())
class NewDevice(Base):
"""新发现设备(OLT 扫描到但尚未补全信息的设备)"""
__tablename__ = "new_devices"
@@ -80,3 +93,18 @@ class NewDevice(Base):
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True)
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
discovered_at = Column(TIMESTAMP, server_default=func.now())
class DeviceReplacement(Base):
"""设备更换记录"""
__tablename__ = "device_replacements"
id = Column(BigInteger, primary_key=True, index=True)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, index=True)
old_mac = Column(String(17), nullable=False)
new_mac = Column(String(17), nullable=False)
reason = Column(Text)
operator_id = Column(String(100)) # 操作人 user_id
operator_name = Column(String(100))
replaced_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
created_at = Column(TIMESTAMP, server_default=func.now())
+117
View File
@@ -0,0 +1,117 @@
"""库存管理数据模型"""
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, Boolean, Numeric, Date
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class MaterialCategory(Base):
__tablename__ = "material_categories"
id = Column(BigInteger, primary_key=True, index=True)
name = Column(String(100), nullable=False)
code = Column(String(50), unique=True, nullable=False)
description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
materials = relationship("Material", back_populates="category")
class Material(Base):
__tablename__ = "materials"
id = Column(BigInteger, primary_key=True, index=True)
category_id = Column(BigInteger, ForeignKey("material_categories.id"))
name = Column(String(200), nullable=False)
model = Column(String(100))
specification = Column(Text)
brand = Column(String(100))
unit = Column(String(20), default="")
safe_quantity = Column(Integer, default=0)
notes = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
category = relationship("MaterialCategory", back_populates="materials")
batches = relationship("InventoryBatch", back_populates="material")
serial_devices = relationship("SerialDevice", back_populates="material")
class InventoryBatch(Base):
__tablename__ = "inventory_batches"
id = Column(BigInteger, primary_key=True, index=True)
material_id = Column(BigInteger, ForeignKey("materials.id"))
batch_no = Column(String(50), nullable=False)
quantity = Column(Integer, nullable=False)
available_quantity = Column(Integer, nullable=False, default=0)
supplier = Column(String(200))
purchase_date = Column(Date)
purchase_price = Column(Numeric(10, 2))
expiry_date = Column(Date)
location = Column(String(100))
status = Column(String(20), default="in_stock") # in_stock, reserved, out_of_stock
created_at = Column(TIMESTAMP, server_default=func.now())
material = relationship("Material", back_populates="batches")
serial_devices = relationship("SerialDevice", back_populates="batch")
class SerialDevice(Base):
__tablename__ = "serial_devices"
id = Column(BigInteger, primary_key=True, index=True)
material_id = Column(BigInteger, ForeignKey("materials.id"))
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"))
serial_no = Column(String(100), unique=True, nullable=False)
mac_address = Column(String(17), index=True)
asset_no = Column(String(50))
status = Column(String(20), default="in_stock", index=True)
# in_stock, allocated, installed, in_use, returned, repairing, scrapped
current_location = Column(String(200))
installed_info = Column(JSONB)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=True)
notes = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
material = relationship("Material", back_populates="serial_devices")
batch = relationship("InventoryBatch", back_populates="serial_devices")
class InventoryTransaction(Base):
__tablename__ = "inventory_transactions"
id = Column(BigInteger, primary_key=True, index=True)
transaction_no = Column(String(50), unique=True, nullable=False)
transaction_type = Column(String(20), nullable=False)
# purchase_in, allocate_out, return_in, scrap_out, adjust
material_id = Column(BigInteger, ForeignKey("materials.id"))
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"), nullable=True)
serial_device_id = Column(BigInteger, ForeignKey("serial_devices.id"), nullable=True)
quantity = Column(Integer, nullable=False)
from_status = Column(String(20))
to_status = Column(String(20))
operator_id = Column(BigInteger, ForeignKey("users.id"))
project_name = Column(String(200))
installation_info = Column(JSONB)
notes = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
class InventoryCheck(Base):
__tablename__ = "inventory_checks"
id = Column(BigInteger, primary_key=True, index=True)
check_no = Column(String(50), unique=True, nullable=False)
check_date = Column(Date, nullable=False)
checker_id = Column(BigInteger, ForeignKey("users.id"))
material_id = Column(BigInteger, ForeignKey("materials.id"))
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"), nullable=True)
book_quantity = Column(Integer)
actual_quantity = Column(Integer)
difference = Column(Integer)
reason = Column(Text)
adjusted = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, server_default=func.now())
+13
View File
@@ -0,0 +1,13 @@
"""系统设置模型"""
from sqlalchemy import Column, String, Text, TIMESTAMP
from sqlalchemy.sql import func
from app.core.database import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
key = Column(String(100), primary_key=True)
value = Column(Text, nullable=False)
description = Column(Text)
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
+238
View File
@@ -0,0 +1,238 @@
"""库存管理 Pydantic 模式"""
from typing import Optional, List, Any
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel
# ── 物料分类 ──────────────────────────────────────────────────────────────────
class CategoryCreate(BaseModel):
name: str
code: str
description: Optional[str] = None
class CategoryResponse(BaseModel):
id: int
name: str
code: str
description: Optional[str] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ── 物料 ──────────────────────────────────────────────────────────────────────
class MaterialCreate(BaseModel):
category_id: int
name: str
model: Optional[str] = None
specification: Optional[str] = None
brand: Optional[str] = None
unit: str = ""
safe_quantity: int = 0
notes: Optional[str] = None
class MaterialUpdate(BaseModel):
name: Optional[str] = None
model: Optional[str] = None
specification: Optional[str] = None
brand: Optional[str] = None
unit: Optional[str] = None
safe_quantity: Optional[int] = None
notes: Optional[str] = None
class MaterialResponse(BaseModel):
id: int
category_id: int
category_name: Optional[str] = None
name: str
model: Optional[str] = None
specification: Optional[str] = None
brand: Optional[str] = None
unit: str
safe_quantity: int
notes: Optional[str] = None
total_quantity: int = 0
available_quantity: int = 0
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class MaterialListResponse(BaseModel):
total: int
items: List[MaterialResponse]
# ── 库存批次 ──────────────────────────────────────────────────────────────────
class BatchCreate(BaseModel):
material_id: int
batch_no: str
quantity: int
supplier: Optional[str] = None
purchase_date: Optional[date] = None
purchase_price: Optional[Decimal] = None
location: Optional[str] = None
notes: Optional[str] = None
class BatchResponse(BaseModel):
id: int
material_id: int
material_name: Optional[str] = None
batch_no: str
quantity: int
available_quantity: int
supplier: Optional[str] = None
purchase_date: Optional[date] = None
purchase_price: Optional[Decimal] = None
location: Optional[str] = None
status: str
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# ── 序列号设备 ────────────────────────────────────────────────────────────────
class SerialDeviceCreate(BaseModel):
material_id: int
batch_id: Optional[int] = None
serial_no: str
mac_address: Optional[str] = None
asset_no: Optional[str] = None
notes: Optional[str] = None
class SerialDeviceResponse(BaseModel):
id: int
material_id: int
material_name: Optional[str] = None
batch_id: Optional[int] = None
serial_no: str
mac_address: Optional[str] = None
asset_no: Optional[str] = None
status: str
current_location: Optional[str] = None
installed_info: Optional[Any] = None
notes: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class SerialDeviceListResponse(BaseModel):
total: int
items: List[SerialDeviceResponse]
# ── 出入库操作 ────────────────────────────────────────────────────────────────
class PurchaseInRequest(BaseModel):
material_id: int
batch_no: str
quantity: int
supplier: Optional[str] = None
purchase_date: Optional[date] = None
purchase_price: Optional[Decimal] = None
location: Optional[str] = None
serial_nos: Optional[List[str]] = None # 高价值设备的序列号列表
notes: Optional[str] = None
class AllocateOutRequest(BaseModel):
serial_device_id: Optional[int] = None # 序列号设备
batch_id: Optional[int] = None # 批次设备
quantity: int = 1
project_name: Optional[str] = None
installation_info: Optional[Any] = None
# 方案三:领用时补录设备标识(适用于批次出库场景)
mac_address: Optional[str] = None # MAC 地址
serial_no: Optional[str] = None # 序列号
asset_no: Optional[str] = None # 资产编号
notes: Optional[str] = None
class ReturnInRequest(BaseModel):
serial_device_id: int
return_type: str # simple, repair, scrap
notes: Optional[str] = None
class TransactionResponse(BaseModel):
id: int
transaction_no: str
transaction_type: str
material_id: int
material_name: Optional[str] = None
quantity: int
from_status: Optional[str] = None
to_status: Optional[str] = None
operator_id: Optional[int] = None
project_name: Optional[str] = None
installation_info: Optional[Any] = None # 包含补录的设备信息
notes: Optional[str] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class TransactionListResponse(BaseModel):
total: int
items: List[TransactionResponse]
# ── 盘点 ──────────────────────────────────────────────────────────────────────
class CheckCreate(BaseModel):
check_date: date
material_id: int
batch_id: Optional[int] = None
actual_quantity: int
reason: Optional[str] = None
class CheckResponse(BaseModel):
id: int
check_no: str
check_date: date
material_id: int
material_name: Optional[str] = None
batch_id: Optional[int] = None
book_quantity: Optional[int] = None
actual_quantity: Optional[int] = None
difference: Optional[int] = None
reason: Optional[str] = None
adjusted: bool
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class CheckListResponse(BaseModel):
total: int
items: List[CheckResponse]
# ── 统计 ──────────────────────────────────────────────────────────────────────
class InventorySummary(BaseModel):
total_materials: int
total_quantity: int
available_quantity: int
allocated_quantity: int
low_stock_count: int
category_stats: List[dict]
+30
View File
@@ -0,0 +1,30 @@
"""用户相关 Schema"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserListItem(BaseModel):
id: int
username: str
email: Optional[str] = None
role: str
assigned_area: Optional[str] = None
assigned_school: Optional[str] = None
is_active: bool
last_login: Optional[datetime] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class UserListResponse(BaseModel):
total: int
items: list[UserListItem]
class UserRoleUpdate(BaseModel):
role: str # admin / area_admin / school_admin / user
assigned_area: Optional[str] = None
assigned_school: Optional[str] = None
+183
View File
@@ -0,0 +1,183 @@
"""审计日志服务"""
import os
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.audit_log import AuditLog
AUDIT_LOG_DIR = os.environ.get("AUDIT_LOG_DIR", "/app/logs/audit")
AUDIT_LOG_RETENTION_DAYS = int(os.environ.get("AUDIT_LOG_RETENTION_DAYS", "90"))
# 路由分类规则:(method, path_pattern, exact, action_type, action_subtype, resource_type, description)
# exact=True 精确匹配路径;exact=False 前缀匹配。
# 精确匹配规则排在前,前缀匹配按从长到短排列,避免短前缀误匹配。
_ROUTE_MAP = [
# ── 认证 ──────────────────────────────────────────────────────
("POST", "/api/auth/callback", True, "auth", "login", "user", "用户登录"),
("GET", "/api/auth/profile", True, "auth", "profile", "user", "查看个人信息"),
# ── OLT:精确路径(不含 ID 段)────────────────────────────────
("GET", "/api/olt/devices", True, "olt", "list", "olt", "查询 OLT 设备列表"),
("POST", "/api/olt/devices", True, "olt", "create", "olt", "新建 OLT 设备"),
("POST", "/api/olt/import", True, "olt", "import", "olt", "批量导入 OLT 设备"),
("POST", "/api/olt/quick-scan", True, "olt", "quick_scan", "olt", "触发快速扫描"),
("POST", "/api/olt/loopback-detection", True, "olt", "loopback", "olt", "触发环路检测"),
# OLT 端口操作(路径含 ID,前缀匹配;/ports/toggle 比 /devices/ 更具体,先列)
("POST", "/api/olt/devices/", False, "olt", "port_toggle", "olt", "切换 OLT 端口状态"),
("GET", "/api/olt/devices/", False, "olt", "port_list", "olt", "查询 OLT 端口列表"),
("PUT", "/api/olt/devices/", False, "olt", "update", "olt", "更新 OLT 设备信息"),
("DELETE", "/api/olt/devices/", False, "olt", "delete", "olt", "删除 OLT 设备"),
# 重复 MAC
("POST", "/api/olt/duplicate-macs/", False, "olt", "port_clear", "olt", "清除重复 MAC 端口占用"),
("DELETE", "/api/olt/duplicate-macs/", False, "olt", "mac_delete", "olt", "删除重复 MAC 记录"),
# 新发现设备
("PUT", "/api/olt/new-devices/", False, "olt", "device_fill", "olt", "补全新发现设备信息"),
("DELETE", "/api/olt/new-devices/", False, "olt", "device_ignore", "olt", "忽略新发现设备"),
# ── ONU 设备 ──────────────────────────────────────────────────
("POST", "/api/check/status", True, "system", "scan_trigger", "device", "手动触发全量状态扫描"),
("POST", "/api/import/upload", True, "device", "import", "device", "批量导入 ONU 设备"),
("DELETE", "/api/devices/status/all", True, "device", "status_clear", "device", "清除所有设备状态"),
("POST", "/api/devices/", False, "device", "refresh", "device", "刷新单台设备在线状态"),
("PUT", "/api/devices/", False, "device", "update", "device", "更新 ONU 设备信息"),
("DELETE", "/api/devices/", False, "device", "delete", "device", "删除 ONU 设备"),
# 设备更换(路径含 /replace,需在 refresh 前匹配,通过 _classify 特殊处理)
("POST", "/replace", False, "device", "replace", "device", "更换设备 MAC 地址"),
# ── 用户管理 ──────────────────────────────────────────────────
("POST", "/api/users", True, "user", "create", "user", "创建用户"),
("PUT", "/api/users/", False, "user", "update", "user", "更新用户信息"),
("DELETE", "/api/users/", False, "user", "delete", "user", "删除用户"),
# ── 角色权限 ──────────────────────────────────────────────────
("PUT", "/api/roles/", False, "user", "role_update", "role", "更新角色权限配置"),
# ── 系统设置 ──────────────────────────────────────────────────
("PUT", "/api/settings/check_interval", True, "system", "config_update", "system", "更新定时扫描间隔"),
("PUT", "/api/settings/", False, "system", "config_update", "system", "更新系统配置"),
# ── 库存管理(精确路径优先)───────────────────────────────────
("POST", "/api/inventory/transactions/purchase", True, "inventory", "purchase", "inventory", "物料采购入库"),
("POST", "/api/inventory/transactions/allocate", True, "inventory", "allocate", "inventory", "物料分配出库"),
("POST", "/api/inventory/transactions/return", True, "inventory", "return", "inventory", "物料退库"),
("POST", "/api/inventory/categories", True, "inventory", "cat_create", "inventory", "新建库存分类"),
("POST", "/api/inventory/materials", True, "inventory", "mat_create", "inventory", "新建物料"),
("PUT", "/api/inventory/materials/", False, "inventory", "mat_update", "inventory", "更新物料信息"),
("DELETE", "/api/inventory/materials/", False, "inventory", "mat_delete", "inventory", "删除物料"),
("POST", "/api/inventory/checks", True, "inventory", "check_create", "inventory", "发起库存盘点"),
("POST", "/api/inventory/checks/", False, "inventory", "check_adjust", "inventory", "盘点差异调整"),
("PUT", "/api/inventory/checks/", False, "inventory", "check_update", "inventory", "更新盘点记录"),
]
def _classify(method: str, path: str):
"""根据请求方法和路径推断操作分类,精确匹配优先于前缀匹配"""
# 第一轮:精确匹配
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if exact and method == m and path == p:
return atype, subtype, rtype, desc
# 第二轮:后缀匹配(path_pattern 以 "/" 开头但不含 "/api",视为后缀)
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if not exact and not p.startswith('/api') and method == m and path.endswith(p):
return atype, subtype, rtype, desc
# 第三轮:前缀匹配(规则列表已按从具体到宽泛排列)
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if not exact and p.startswith('/api') and method == m and path.startswith(p):
return atype, subtype, rtype, desc
return "system", "request", "unknown", f"{method} {path}"
def _get_ip(request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return ""
def write_audit_log(
db: Session,
*,
user_id: str,
username: str,
user_role: str,
method: str,
path: str,
ip_address: str = "",
user_agent: str = "",
status_code: int,
request_params: Optional[dict] = None,
response_data: Optional[dict] = None,
error_message: Optional[str] = None,
description: Optional[str] = None,
resource_id: Optional[str] = None,
resource_name: Optional[str] = None,
):
action_type, action_subtype, resource_type, default_desc = _classify(method, path)
status = "success" if status_code < 400 else ("failed" if status_code < 500 else "error")
log = AuditLog(
user_id=user_id,
username=username,
user_role=user_role,
action_type=action_type,
action_subtype=action_subtype,
ip_address=ip_address,
user_agent=user_agent[:500] if user_agent else "",
request_method=method,
request_path=path,
status=status,
status_code=status_code,
resource_type=resource_type,
resource_id=resource_id,
resource_name=resource_name,
description=description or default_desc,
request_params=request_params,
response_data=response_data if status_code < 400 else None,
error_message=error_message,
)
db.add(log)
db.commit()
return log.id
def query_logs(
db: Session,
*,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
user_id: Optional[str] = None,
username: Optional[str] = None,
action_type: Optional[str] = None,
resource_type: Optional[str] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 50,
):
q = db.query(AuditLog)
filters = []
if start_time:
filters.append(AuditLog.action_time >= start_time)
if end_time:
filters.append(AuditLog.action_time <= end_time)
if user_id:
filters.append(AuditLog.user_id == user_id)
if username:
filters.append(AuditLog.username.ilike(f"%{username}%"))
if action_type:
filters.append(AuditLog.action_type == action_type)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
if status:
filters.append(AuditLog.status == status)
if filters:
q = q.filter(and_(*filters))
total = q.count()
items = q.order_by(AuditLog.action_time.desc()).offset((page - 1) * page_size).limit(page_size).all()
return total, items
def cleanup_old_logs(db: Session, retention_days: int = AUDIT_LOG_RETENTION_DAYS):
"""清理超过保留期的日志"""
cutoff = datetime.utcnow() - timedelta(days=retention_days)
deleted = db.query(AuditLog).filter(AuditLog.created_at < cutoff).delete()
db.commit()
return deleted
+543
View File
@@ -0,0 +1,543 @@
"""库存管理业务逻辑"""
import uuid
from datetime import date, datetime
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import func, text
from fastapi import HTTPException
from app.models.inventory import (
MaterialCategory, Material, InventoryBatch,
SerialDevice, InventoryTransaction, InventoryCheck
)
def _gen_no(prefix: str) -> str:
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}"
# ── 物料分类 ──────────────────────────────────────────────────────────────────
def get_categories(db: Session) -> List[MaterialCategory]:
return db.query(MaterialCategory).order_by(MaterialCategory.id).all()
def create_category(db: Session, name: str, code: str, description: Optional[str]) -> MaterialCategory:
if db.query(MaterialCategory).filter(MaterialCategory.code == code).first():
raise HTTPException(status_code=400, detail="分类代码已存在")
cat = MaterialCategory(name=name, code=code, description=description)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
# ── 物料 ──────────────────────────────────────────────────────────────────────
def get_materials(db: Session, skip: int, limit: int, category_id: Optional[int], keyword: Optional[str]):
q = db.query(Material)
if category_id:
q = q.filter(Material.category_id == category_id)
if keyword:
q = q.filter(Material.name.contains(keyword) | Material.model.contains(keyword))
total = q.count()
items = q.order_by(Material.id).offset(skip).limit(limit).all()
result = []
for m in items:
total_qty = db.query(func.sum(InventoryBatch.quantity)).filter(
InventoryBatch.material_id == m.id
).scalar() or 0
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
InventoryBatch.material_id == m.id
).scalar() or 0
item = {
"id": m.id,
"category_id": m.category_id,
"category_name": m.category.name if m.category else None,
"name": m.name,
"model": m.model,
"specification": m.specification,
"brand": m.brand,
"unit": m.unit,
"safe_quantity": m.safe_quantity,
"notes": m.notes,
"total_quantity": total_qty,
"available_quantity": avail_qty,
"created_at": m.created_at,
}
result.append(item)
return {"total": total, "items": result}
def create_material(db: Session, data: dict) -> Material:
m = Material(**data)
db.add(m)
db.commit()
db.refresh(m)
return m
def update_material(db: Session, material_id: int, data: dict) -> Material:
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
for k, v in data.items():
if v is not None:
setattr(m, k, v)
db.commit()
db.refresh(m)
return m
def delete_material(db: Session, material_id: int):
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
has_stock = db.query(InventoryBatch).filter(
InventoryBatch.material_id == material_id,
InventoryBatch.available_quantity > 0,
).first()
if has_stock:
raise HTTPException(status_code=400, detail="该物料仍有库存,请先出库后再删除")
db.delete(m)
db.commit()
# ── 采购入库 ──────────────────────────────────────────────────────────────────
def purchase_in(db: Session, data: dict, operator_id: int) -> dict:
material_id = data["material_id"]
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
serial_nos = data.pop("serial_nos", None) or []
quantity = data["quantity"]
batch = InventoryBatch(
material_id=material_id,
batch_no=data["batch_no"],
quantity=quantity,
available_quantity=quantity,
supplier=data.get("supplier"),
purchase_date=data.get("purchase_date"),
purchase_price=data.get("purchase_price"),
location=data.get("location"),
status="in_stock",
)
db.add(batch)
db.flush()
# 高价值设备:逐个创建序列号记录
for sn in serial_nos:
sd = SerialDevice(
material_id=material_id,
batch_id=batch.id,
serial_no=sn,
status="in_stock",
)
db.add(sd)
txn = InventoryTransaction(
transaction_no=_gen_no("PI"),
transaction_type="purchase_in",
material_id=material_id,
batch_id=batch.id,
quantity=quantity,
from_status=None,
to_status="in_stock",
operator_id=operator_id,
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "入库成功", "batch_id": batch.id, "transaction_no": txn.transaction_no}
# ── 领用出库 ──────────────────────────────────────────────────────────────────
def allocate_out(db: Session, data: dict, operator_id: int) -> dict:
serial_device_id = data.get("serial_device_id")
batch_id = data.get("batch_id")
quantity = data.get("quantity", 1)
if serial_device_id:
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
if not sd:
raise HTTPException(status_code=404, detail="设备不存在")
if sd.status != "in_stock":
raise HTTPException(status_code=400, detail=f"设备当前状态为 {sd.status},无法领用")
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
if batch:
batch.available_quantity = max(0, batch.available_quantity - 1)
sd.status = "allocated"
sd.installed_info = data.get("installation_info")
txn = InventoryTransaction(
transaction_no=_gen_no("AO"),
transaction_type="allocate_out",
material_id=sd.material_id,
batch_id=sd.batch_id,
serial_device_id=sd.id,
quantity=1,
from_status="in_stock",
to_status="allocated",
operator_id=operator_id,
project_name=data.get("project_name"),
installation_info=data.get("installation_info"),
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "领用成功", "transaction_no": txn.transaction_no}
elif batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
if not batch:
raise HTTPException(status_code=404, detail="批次不存在")
if batch.available_quantity < quantity:
raise HTTPException(status_code=400, detail="库存不足")
batch.available_quantity -= quantity
# 方案三:领用时补录设备标识,存入 installation_info
device_info = data.get("installation_info") or {}
if isinstance(device_info, str):
device_info = {}
mac = data.get("mac_address", "").strip() if data.get("mac_address") else ""
sn = data.get("serial_no", "").strip() if data.get("serial_no") else ""
asset = data.get("asset_no", "").strip() if data.get("asset_no") else ""
if mac or sn or asset:
device_info = {k: v for k, v in {"mac_address": mac, "serial_no": sn, "asset_no": asset}.items() if v}
txn = InventoryTransaction(
transaction_no=_gen_no("AO"),
transaction_type="allocate_out",
material_id=batch.material_id,
batch_id=batch_id,
quantity=quantity,
from_status="in_stock",
to_status="allocated",
operator_id=operator_id,
project_name=data.get("project_name"),
installation_info=device_info if device_info else None,
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "领用成功", "transaction_no": txn.transaction_no}
raise HTTPException(status_code=400, detail="需要指定序列号设备或批次")
# ── 退库 ──────────────────────────────────────────────────────────────────────
def return_in(db: Session, serial_device_id: int, return_type: str, notes: Optional[str], operator_id: int) -> dict:
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
if not sd:
raise HTTPException(status_code=404, detail="设备不存在")
from_status = sd.status
if return_type == "scrap":
to_status = "scrapped"
elif return_type == "repair":
to_status = "repairing"
else:
to_status = "in_stock"
# 归还时恢复批次可用数量
if sd.batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
if batch:
batch.available_quantity += 1
sd.status = to_status
txn = InventoryTransaction(
transaction_no=_gen_no("RI"),
transaction_type="return_in" if return_type != "scrap" else "scrap_out",
material_id=sd.material_id,
batch_id=sd.batch_id,
serial_device_id=sd.id,
quantity=1,
from_status=from_status,
to_status=to_status,
operator_id=operator_id,
notes=notes,
)
db.add(txn)
db.commit()
return {"message": "退库成功", "transaction_no": txn.transaction_no}
# ── 批次查询 ──────────────────────────────────────────────────────────────────
def get_batches_by_material(db: Session, material_id: int) -> list:
batches = db.query(InventoryBatch).filter(
InventoryBatch.material_id == material_id,
InventoryBatch.available_quantity > 0,
).order_by(InventoryBatch.id.desc()).all()
return [
{
"id": b.id,
"batch_no": b.batch_no,
"quantity": b.quantity,
"available_quantity": b.available_quantity,
"supplier": b.supplier,
"location": b.location,
}
for b in batches
]
# ── 序列号设备查询 ────────────────────────────────────────────────────────────
def get_serial_devices(db: Session, skip: int, limit: int, material_id: Optional[int], status: Optional[str], keyword: Optional[str]):
q = db.query(SerialDevice)
if material_id:
q = q.filter(SerialDevice.material_id == material_id)
if status:
q = q.filter(SerialDevice.status == status)
if keyword:
q = q.filter(
SerialDevice.serial_no.contains(keyword) |
SerialDevice.mac_address.contains(keyword) |
SerialDevice.asset_no.contains(keyword)
)
total = q.count()
items = q.order_by(SerialDevice.id.desc()).offset(skip).limit(limit).all()
result = []
for sd in items:
result.append({
"id": sd.id,
"material_id": sd.material_id,
"material_name": sd.material.name if sd.material else None,
"batch_id": sd.batch_id,
"serial_no": sd.serial_no,
"mac_address": sd.mac_address,
"asset_no": sd.asset_no,
"status": sd.status,
"current_location": sd.current_location,
"installed_info": sd.installed_info,
"notes": sd.notes,
"created_at": sd.created_at,
"updated_at": sd.updated_at,
})
return {"total": total, "items": result}
def get_transaction_detail(db: Session, transaction_id: int) -> dict:
t = db.query(InventoryTransaction).filter(InventoryTransaction.id == transaction_id).first()
if not t:
raise HTTPException(status_code=404, detail="记录不存在")
m = db.query(Material).filter(Material.id == t.material_id).first()
batch = db.query(InventoryBatch).filter(InventoryBatch.id == t.batch_id).first() if t.batch_id else None
sd = db.query(SerialDevice).filter(SerialDevice.id == t.serial_device_id).first() if t.serial_device_id else None
return {
"id": t.id,
"transaction_no": t.transaction_no,
"transaction_type": t.transaction_type,
"material_id": t.material_id,
"material_name": m.name if m else None,
"material_model": m.model if m else None,
"material_brand": m.brand if m else None,
"quantity": t.quantity,
"from_status": t.from_status,
"to_status": t.to_status,
"operator_id": t.operator_id,
"project_name": t.project_name,
"installation_info": t.installation_info,
"notes": t.notes,
"created_at": t.created_at,
"batch": {
"id": batch.id,
"batch_no": batch.batch_no,
"supplier": batch.supplier,
"purchase_date": str(batch.purchase_date) if batch.purchase_date else None,
"purchase_price": str(batch.purchase_price) if batch.purchase_price else None,
"location": batch.location,
} if batch else None,
"serial_device": {
"id": sd.id,
"serial_no": sd.serial_no,
"mac_address": sd.mac_address,
"asset_no": sd.asset_no,
} if sd else None,
}
def get_transactions(db: Session, skip: int, limit: int, transaction_type: Optional[str], material_id: Optional[int]):
q = db.query(InventoryTransaction)
if transaction_type:
q = q.filter(InventoryTransaction.transaction_type == transaction_type)
if material_id:
q = q.filter(InventoryTransaction.material_id == material_id)
total = q.count()
items = q.order_by(InventoryTransaction.id.desc()).offset(skip).limit(limit).all()
result = []
for t in items:
m = db.query(Material).filter(Material.id == t.material_id).first()
result.append({
"id": t.id,
"transaction_no": t.transaction_no,
"transaction_type": t.transaction_type,
"material_id": t.material_id,
"material_name": m.name if m else None,
"quantity": t.quantity,
"from_status": t.from_status,
"to_status": t.to_status,
"operator_id": t.operator_id,
"project_name": t.project_name,
"installation_info": t.installation_info,
"notes": t.notes,
"created_at": t.created_at,
})
return {"total": total, "items": result}
# ── 库存总览 ──────────────────────────────────────────────────────────────────
def get_summary(db: Session) -> dict:
total_materials = db.query(Material).count()
total_qty = db.query(func.sum(InventoryBatch.quantity)).scalar() or 0
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).scalar() or 0
allocated_qty = total_qty - avail_qty
# 低库存物料数
low_stock = db.execute(text("""
SELECT COUNT(*) FROM (
SELECT m.id, m.safe_quantity, COALESCE(SUM(b.available_quantity), 0) AS avail
FROM materials m
LEFT JOIN inventory_batches b ON b.material_id = m.id
GROUP BY m.id, m.safe_quantity
HAVING COALESCE(SUM(b.available_quantity), 0) <= m.safe_quantity AND m.safe_quantity > 0
) t
""")).scalar() or 0
# 按分类统计
rows = db.execute(text("""
SELECT c.name, COUNT(m.id) AS material_count,
COALESCE(SUM(b.available_quantity), 0) AS available
FROM material_categories c
LEFT JOIN materials m ON m.category_id = c.id
LEFT JOIN inventory_batches b ON b.material_id = m.id
GROUP BY c.id, c.name
ORDER BY c.id
""")).fetchall()
category_stats = [{"name": r[0], "material_count": r[1], "available": r[2]} for r in rows]
return {
"total_materials": total_materials,
"total_quantity": total_qty,
"available_quantity": avail_qty,
"allocated_quantity": allocated_qty,
"low_stock_count": low_stock,
"category_stats": category_stats,
}
# ── 盘点 ──────────────────────────────────────────────────────────────────────
def create_check(db: Session, data: dict, checker_id: int) -> InventoryCheck:
material_id = data["material_id"]
batch_id = data.get("batch_id")
if batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
book_qty = batch.available_quantity if batch else 0
else:
book_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
InventoryBatch.material_id == material_id
).scalar() or 0
actual_qty = data["actual_quantity"]
diff = actual_qty - book_qty
check = InventoryCheck(
check_no=_gen_no("CK"),
check_date=data["check_date"],
checker_id=checker_id,
material_id=material_id,
batch_id=batch_id,
book_quantity=book_qty,
actual_quantity=actual_qty,
difference=diff,
reason=data.get("reason"),
adjusted=False,
)
db.add(check)
db.commit()
db.refresh(check)
return check
def adjust_check(db: Session, check_id: int, operator_id: int) -> dict:
check = db.query(InventoryCheck).filter(InventoryCheck.id == check_id).first()
if not check:
raise HTTPException(status_code=404, detail="盘点记录不存在")
if check.adjusted:
raise HTTPException(status_code=400, detail="已调整过")
if check.batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == check.batch_id).first()
if batch:
batch.available_quantity = check.actual_quantity
else:
# 调整第一个批次(简化处理)
batch = db.query(InventoryBatch).filter(
InventoryBatch.material_id == check.material_id
).first()
if batch:
batch.available_quantity = check.actual_quantity
txn = InventoryTransaction(
transaction_no=_gen_no("ADJ"),
transaction_type="adjust",
material_id=check.material_id,
batch_id=check.batch_id,
quantity=abs(check.difference or 0),
from_status="in_stock",
to_status="in_stock",
operator_id=operator_id,
notes=f"盘点调整:{check.check_no},差异 {check.difference}",
)
db.add(txn)
check.adjusted = True
db.commit()
return {"message": "调整成功"}
def get_checks(db: Session, skip: int, limit: int, material_id: Optional[int]):
q = db.query(InventoryCheck)
if material_id:
q = q.filter(InventoryCheck.material_id == material_id)
total = q.count()
items = q.order_by(InventoryCheck.id.desc()).offset(skip).limit(limit).all()
result = []
for c in items:
m = db.query(Material).filter(Material.id == c.material_id).first()
result.append({
"id": c.id,
"check_no": c.check_no,
"check_date": c.check_date,
"material_id": c.material_id,
"material_name": m.name if m else None,
"batch_id": c.batch_id,
"book_quantity": c.book_quantity,
"actual_quantity": c.actual_quantity,
"difference": c.difference,
"reason": c.reason,
"adjusted": c.adjusted,
"created_at": c.created_at,
})
return {"total": total, "items": result}
+17 -5
View File
@@ -165,14 +165,23 @@ class SSHService:
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
# 提取状态字段
if 'Up' in line:
if re.search(r'\b(up|online)\b', line.lower()):
devices[mac] = 'online'
elif 'Offline' in line:
elif re.search(r'\b(offline|down)\b', line.lower()):
devices[mac] = 'offline'
return devices
def _clean_output(self, output: str) -> str:
"""清理终端控制字符和 More 分页标记,避免污染解析"""
# 移除 ANSI 转义序列
output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
# 移除 ---- More ---- 行(含前后控制字符)
output = re.sub(r'---- More ----[^\n]*', '', output)
# 将独立的 \r(不跟 \n)替换为空,避免覆盖行内容
output = re.sub(r'\r(?!\n)', '', output)
return output
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
"""增强解析:提取完整 ONU 信息
返回: (unique_devices, duplicate_devices)
@@ -180,6 +189,7 @@ class SSHService:
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
"""
all_records: Dict[str, List[ONUInfo]] = {}
output = self._clean_output(output)
lines = output.split('\n')
current_slot = None
@@ -227,9 +237,11 @@ class SSHService:
mac = mac_match.group(1).lower()
# 提取状态
# 提取状态:H3C OLT 不同固件版本可能输出 Up/UP/up/Online/online
status = 'offline'
if 'Up' in line:
line_lower = line.lower()
# 检查行末状态字段(避免误匹配 "Onu" 中的字母)
if re.search(r'\b(up|online)\b', line_lower):
status = 'online'
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
+62
View File
@@ -0,0 +1,62 @@
"""审计日志 Celery 任务"""
import traceback
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
@celery_app.task(queue='h3c_onu_ms', ignore_result=True)
def create_audit_log_task(
user_id: str,
username: str,
user_role: str,
method: str,
path: str,
ip_address: str,
user_agent: str,
status_code: int,
request_params: dict = None,
response_data: dict = None,
error_message: str = None,
description: str = None,
resource_id: str = None,
resource_name: str = None,
):
"""异步写入审计日志,不阻塞主请求流程"""
from app.services.audit_service import write_audit_log
db = SessionLocal()
try:
write_audit_log(
db,
user_id=user_id,
username=username,
user_role=user_role,
method=method,
path=path,
ip_address=ip_address,
user_agent=user_agent,
status_code=status_code,
request_params=request_params,
response_data=response_data,
error_message=error_message,
description=description,
resource_id=resource_id,
resource_name=resource_name,
)
except Exception:
pass # 审计日志失败不影响主业务
finally:
db.close()
@celery_app.task(queue='h3c_onu_ms')
def cleanup_audit_logs_task():
"""清理90天前的审计日志(每天执行)"""
from app.services.audit_service import cleanup_old_logs
db = SessionLocal()
try:
deleted = cleanup_old_logs(db)
return {'success': True, 'deleted': deleted}
except Exception as e:
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
finally:
db.close()
+105 -1
View File
@@ -1,13 +1,56 @@
"""状态检查任务"""
import time
import traceback
import redis as redis_lib
from datetime import datetime, timedelta
from sqlalchemy import func, case
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
from app.core.config import settings
from app.services.check_service import CheckService
_LAST_RUN_KEY = "check_all_devices:last_run"
_RUNNING_KEY = "check_all_devices:running"
_INTERVAL_REDIS_KEY = "system:check_interval_seconds"
def _get_redis():
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
def _get_check_interval(r) -> int:
"""从 Redis 读取配置间隔,回退到 DB,再回退到默认值"""
cached = r.get(_INTERVAL_REDIS_KEY)
if cached:
return int(cached)
# 从 DB 读取并缓存
db = SessionLocal()
try:
from app.models.setting import SystemSetting
setting = db.query(SystemSetting).filter_by(key='check_interval_seconds').first()
interval = int(setting.value) if setting else settings.CHECK_INTERVAL
r.set(_INTERVAL_REDIS_KEY, str(interval))
return interval
finally:
db.close()
@celery_app.task(bind=True)
def check_all_devices(self):
"""检查所有设备状态"""
"""检查所有设备状态(支持可配置间隔,最小5分钟)"""
r = _get_redis()
interval = _get_check_interval(r)
# Redis 节流:检查距上次运行是否已超过配置间隔
last_run = r.get(_LAST_RUN_KEY)
now = time.time()
if last_run and (now - float(last_run)) < interval:
remaining = int(interval - (now - float(last_run)))
return {'skipped': True, 'reason': f'间隔未到,还需等待 {remaining}', 'interval': interval}
# 标记正在运行(TTL 10分钟防止异常时永久卡住)
r.set(_RUNNING_KEY, '1', ex=600)
db = SessionLocal()
self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'})
try:
@@ -68,5 +111,66 @@ def check_all_devices(self):
'error': str(e),
'traceback': traceback.format_exc()
}
finally:
# 任务完成后记录时间、清除运行标记
r.set(_LAST_RUN_KEY, str(time.time()))
r.delete(_RUNNING_KEY)
db.close()
@celery_app.task
def aggregate_daily_snapshot():
"""聚合昨日设备状态快照(每天凌晨执行)"""
from app.models.device import DeviceStatusHistory, DeviceDailySnapshot, ONUDevice
db = SessionLocal()
try:
yesterday = (datetime.utcnow() - timedelta(days=1)).date()
date_str = yesterday.strftime('%Y-%m-%d')
# 如果已存在则跳过(幂等)
exists = db.query(DeviceDailySnapshot).filter_by(snapshot_date=date_str).first()
if exists:
return {'skipped': True, 'date': date_str}
# 昨天每台设备的最后一次检查状态
day_start = datetime.combine(yesterday, datetime.min.time())
day_end = datetime.combine(yesterday, datetime.max.time())
daily_latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
)
.filter(DeviceStatusHistory.checked_at.between(day_start, day_end))
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
row = (
db.query(
func.count().label("total"),
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
)
.join(
daily_latest_subq,
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
)
.one()
)
snapshot = DeviceDailySnapshot(
snapshot_date=date_str,
total=int(row.total or 0),
online=int(row.online or 0),
offline=int(row.offline or 0),
)
db.add(snapshot)
db.commit()
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
except Exception as e:
db.rollback()
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
finally:
db.close()