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}