```
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息 - 在JWT令牌中添加用户角色信息 - 新增get_my_permissions接口用于获取当前用户权限码列表 - 重构认证回调逻辑,增加错误日志记录 - 更新用户信息获取接口使用Authorization头验证 ```
This commit is contained in:
@@ -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
|
||||
@@ -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="无效的令牌")
|
||||
|
||||
@@ -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
@@ -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
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "权限更新成功"}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user