```
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息 - 在JWT令牌中添加用户角色信息 - 新增get_my_permissions接口用于获取当前用户权限码列表 - 重构认证回调逻辑,增加错误日志记录 - 更新用户信息获取接口使用Authorization头验证 ```
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""rebuild users, permissions, role_permissions tables
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 3e108afa9bba
|
||||
Create Date: 2026-04-05
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '3e108afa9bba'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
insp = sa.inspect(conn)
|
||||
existing = insp.get_table_names()
|
||||
|
||||
# 创建 permissions 表(如不存在)
|
||||
if 'permissions' not in existing:
|
||||
op.create_table(
|
||||
'permissions',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('code', sa.String(length=50), nullable=False),
|
||||
sa.Column('module', sa.String(length=50), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code'),
|
||||
)
|
||||
|
||||
# 创建 users 表(如不存在)
|
||||
if 'users' not in existing:
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('casdoor_id', sa.String(length=100), nullable=False),
|
||||
sa.Column('username', sa.String(length=100), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=True),
|
||||
sa.Column('role', sa.String(length=50), nullable=True, server_default='user'),
|
||||
sa.Column('assigned_area', sa.String(length=100), nullable=True),
|
||||
sa.Column('assigned_school', sa.String(length=200), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
|
||||
sa.Column('last_login', postgresql.TIMESTAMP(), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('casdoor_id'),
|
||||
)
|
||||
op.create_index('ix_users_id', 'users', ['id'], unique=False)
|
||||
|
||||
# 创建 role_permissions 关联表(如不存在)
|
||||
if 'role_permissions' not in existing:
|
||||
op.create_table(
|
||||
'role_permissions',
|
||||
sa.Column('role', sa.String(length=50), nullable=False),
|
||||
sa.Column('permission_id', sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id']),
|
||||
sa.PrimaryKeyConstraint('role', 'permission_id'),
|
||||
)
|
||||
|
||||
# 插入默认权限数据(跳过已存在的)
|
||||
permissions_table = sa.table(
|
||||
'permissions',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('code', sa.String),
|
||||
sa.column('module', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
)
|
||||
op.bulk_insert(permissions_table, [
|
||||
{'name': '查看设备', 'code': 'device.view', 'module': 'device', 'description': '查看设备列表和详情'},
|
||||
{'name': '触发检查', 'code': 'device.check', 'module': 'device', 'description': '手动触发设备状态检查'},
|
||||
{'name': '导入设备', 'code': 'device.import', 'module': 'device', 'description': '通过Excel导入设备数据'},
|
||||
{'name': '编辑设备', 'code': 'device.edit', 'module': 'device', 'description': '编辑设备信息'},
|
||||
{'name': '删除设备', 'code': 'device.delete', 'module': 'device', 'description': '删除设备记录'},
|
||||
{'name': '查看OLT', 'code': 'olt.view', 'module': 'olt', 'description': '查看OLT设备列表'},
|
||||
{'name': '管理OLT', 'code': 'olt.manage', 'module': 'olt', 'description': '添加、编辑、删除OLT设备'},
|
||||
{'name': '查看用户', 'code': 'user.view', 'module': 'user', 'description': '查看用户列表'},
|
||||
{'name': '管理用户', 'code': 'user.manage', 'module': 'user', 'description': '修改用户角色和权限'},
|
||||
{'name': '系统管理', 'code': 'system.admin', 'module': 'system', 'description': '系统级管理操作'},
|
||||
])
|
||||
|
||||
# 插入默认角色权限
|
||||
op.execute("""
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'area_admin', id FROM permissions WHERE code IN ('device.view', 'device.check', 'olt.view')
|
||||
""")
|
||||
op.execute("""
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'school_admin', id FROM permissions WHERE code IN ('device.view')
|
||||
""")
|
||||
op.execute("""
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'user', id FROM permissions WHERE code IN ('device.view')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('role_permissions')
|
||||
op.drop_index('ix_users_id', table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('permissions')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add olt.port_manage, olt.discover, olt.loopback permissions
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'b2c3d4e5f6a7'
|
||||
down_revision = 'a1b2c3d4e5f6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
INSERT INTO permissions (name, code, module, description)
|
||||
VALUES
|
||||
('端口管理', 'olt.port_manage', 'olt', '查看和切换OLT端口状态'),
|
||||
('扫描入库', 'olt.discover', 'olt', '扫描OLT并将新设备写入数据库'),
|
||||
('环路检测', 'olt.loopback', 'olt', '对OLT执行环路检测')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DELETE FROM permissions WHERE code IN ('olt.port_manage', 'olt.discover', 'olt.loopback')")
|
||||
@@ -0,0 +1,192 @@
|
||||
"""add inventory management tables and permissions
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-04-05
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = 'c3d4e5f6a7b8'
|
||||
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 物料分类表
|
||||
op.create_table(
|
||||
'material_categories',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('code', sa.String(length=50), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code'),
|
||||
)
|
||||
|
||||
# 物料主数据表
|
||||
op.create_table(
|
||||
'materials',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('category_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('model', sa.String(length=100), nullable=True),
|
||||
sa.Column('specification', sa.Text(), nullable=True),
|
||||
sa.Column('brand', sa.String(length=100), nullable=True),
|
||||
sa.Column('unit', sa.String(length=20), server_default='个', nullable=True),
|
||||
sa.Column('safe_quantity', sa.Integer(), server_default='0', nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['material_categories.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_materials_id', 'materials', ['id'], unique=False)
|
||||
|
||||
# 库存批次表
|
||||
op.create_table(
|
||||
'inventory_batches',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('batch_no', sa.String(length=50), nullable=False),
|
||||
sa.Column('quantity', sa.Integer(), nullable=False),
|
||||
sa.Column('available_quantity', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('supplier', sa.String(length=200), nullable=True),
|
||||
sa.Column('purchase_date', sa.Date(), nullable=True),
|
||||
sa.Column('purchase_price', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('expiry_date', sa.Date(), nullable=True),
|
||||
sa.Column('location', sa.String(length=100), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), server_default='in_stock', nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# 序列号设备表
|
||||
op.create_table(
|
||||
'serial_devices',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('batch_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('serial_no', sa.String(length=100), nullable=False),
|
||||
sa.Column('mac_address', sa.String(length=17), nullable=True),
|
||||
sa.Column('asset_no', sa.String(length=50), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), server_default='in_stock', nullable=True),
|
||||
sa.Column('current_location', sa.String(length=200), nullable=True),
|
||||
sa.Column('installed_info', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('onu_device_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
|
||||
sa.ForeignKeyConstraint(['onu_device_id'], ['onu_devices.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('serial_no'),
|
||||
)
|
||||
op.create_index('ix_serial_devices_mac_address', 'serial_devices', ['mac_address'], unique=False)
|
||||
op.create_index('ix_serial_devices_status', 'serial_devices', ['status'], unique=False)
|
||||
|
||||
# 出入库记录表
|
||||
op.create_table(
|
||||
'inventory_transactions',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('transaction_no', sa.String(length=50), nullable=False),
|
||||
sa.Column('transaction_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('material_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('batch_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('serial_device_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=False),
|
||||
sa.Column('from_status', sa.String(length=20), nullable=True),
|
||||
sa.Column('to_status', sa.String(length=20), nullable=True),
|
||||
sa.Column('operator_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('project_name', sa.String(length=200), nullable=True),
|
||||
sa.Column('installation_info', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
|
||||
sa.ForeignKeyConstraint(['operator_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['serial_device_id'], ['serial_devices.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('transaction_no'),
|
||||
)
|
||||
|
||||
# 盘点记录表
|
||||
op.create_table(
|
||||
'inventory_checks',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('check_no', sa.String(length=50), nullable=False),
|
||||
sa.Column('check_date', sa.Date(), nullable=False),
|
||||
sa.Column('checker_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('material_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('batch_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('book_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('actual_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('difference', sa.Integer(), nullable=True),
|
||||
sa.Column('reason', sa.Text(), nullable=True),
|
||||
sa.Column('adjusted', sa.Boolean(), server_default='false', nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
|
||||
sa.ForeignKeyConstraint(['checker_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('check_no'),
|
||||
)
|
||||
|
||||
# 新增库存管理权限
|
||||
permissions_table = sa.table(
|
||||
'permissions',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('code', sa.String),
|
||||
sa.column('module', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
)
|
||||
op.bulk_insert(permissions_table, [
|
||||
{'name': '查看库存', 'code': 'inventory.view', 'module': 'inventory', 'description': '查看库存列表和统计'},
|
||||
{'name': '管理物料', 'code': 'inventory.manage', 'module': 'inventory', 'description': '创建、编辑、删除物料'},
|
||||
{'name': '出入库操作', 'code': 'inventory.transaction', 'module': 'inventory', 'description': '执行采购入库、领用出库、退库操作'},
|
||||
{'name': '库存盘点', 'code': 'inventory.check', 'module': 'inventory', 'description': '创建盘点单并执行库存调整'},
|
||||
{'name': '库存报表', 'code': 'inventory.report', 'module': 'inventory', 'description': '查看库存统计报表'},
|
||||
])
|
||||
|
||||
# 默认给 area_admin 查看权限
|
||||
op.execute("""
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'area_admin', id FROM permissions WHERE code = 'inventory.view'
|
||||
""")
|
||||
|
||||
# 插入默认物料分类
|
||||
op.execute("""
|
||||
INSERT INTO material_categories (name, code, description) VALUES
|
||||
('ONU设备', 'ONU', '光网络单元设备'),
|
||||
('OLT设备', 'OLT', '光线路终端设备'),
|
||||
('交换机', 'SWITCH', '网络交换机'),
|
||||
('防火墙', 'FIREWALL', '网络防火墙设备'),
|
||||
('上网行为管理', 'BEHAVIOR', '上网行为管理设备'),
|
||||
('光模块及配件', 'ACCESSORY', '光模块、跳线等配件')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('inventory_checks')
|
||||
op.drop_table('inventory_transactions')
|
||||
op.drop_index('ix_serial_devices_status', table_name='serial_devices')
|
||||
op.drop_index('ix_serial_devices_mac_address', table_name='serial_devices')
|
||||
op.drop_table('serial_devices')
|
||||
op.drop_table('inventory_batches')
|
||||
op.drop_index('ix_materials_id', table_name='materials')
|
||||
op.drop_table('materials')
|
||||
op.drop_table('material_categories')
|
||||
op.execute("""
|
||||
DELETE FROM role_permissions WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE module = 'inventory'
|
||||
)
|
||||
""")
|
||||
op.execute("DELETE FROM permissions WHERE module = 'inventory'")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""add region to olt_devices
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-04-05
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'd4e5f6a7b8c9'
|
||||
down_revision = 'c3d4e5f6a7b8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('olt_devices', sa.Column('region', sa.String(100), nullable=True))
|
||||
op.create_index('ix_olt_devices_region', 'olt_devices', ['region'])
|
||||
# 默认将现有 OLT 设置为城区
|
||||
op.execute("UPDATE olt_devices SET region = '城区' WHERE region IS NULL")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_olt_devices_region', 'olt_devices')
|
||||
op.drop_column('olt_devices', 'region')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add device_daily_snapshots table
|
||||
|
||||
Revision ID: e5f6a7b8c9d0
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-04-05
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'e5f6a7b8c9d0'
|
||||
down_revision = 'd4e5f6a7b8c9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'device_daily_snapshots',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True, index=True),
|
||||
sa.Column('snapshot_date', sa.String(10), nullable=False),
|
||||
sa.Column('total', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('online', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('offline', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()')),
|
||||
)
|
||||
op.create_index('ix_device_daily_snapshots_snapshot_date', 'device_daily_snapshots', ['snapshot_date'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_device_daily_snapshots_snapshot_date', 'device_daily_snapshots')
|
||||
op.drop_table('device_daily_snapshots')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add system_settings table
|
||||
|
||||
Revision ID: f6a7b8c9d0e1
|
||||
Revises: e5f6a7b8c9d0
|
||||
Create Date: 2026-04-05
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'f6a7b8c9d0e1'
|
||||
down_revision = 'e5f6a7b8c9d0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'system_settings',
|
||||
sa.Column('key', sa.String(100), primary_key=True),
|
||||
sa.Column('value', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('now()')),
|
||||
)
|
||||
# 插入默认值
|
||||
op.execute("""
|
||||
INSERT INTO system_settings (key, value, description)
|
||||
VALUES ('check_interval_seconds', '1800', '定时检查间隔(秒),最小 300(5分钟)')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('system_settings')
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add audit_logs table
|
||||
|
||||
Revision ID: g7h8i9j0k1l2
|
||||
Revises: f6a7b8c9d0e1
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = 'g7h8i9j0k1l2'
|
||||
down_revision = 'f6a7b8c9d0e1'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'audit_logs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.String(100), nullable=False),
|
||||
sa.Column('username', sa.String(100), nullable=False),
|
||||
sa.Column('user_role', sa.String(50)),
|
||||
sa.Column('action_time', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('action_type', sa.String(50), nullable=False),
|
||||
sa.Column('action_subtype', sa.String(50)),
|
||||
sa.Column('ip_address', sa.String(45)),
|
||||
sa.Column('user_agent', sa.Text()),
|
||||
sa.Column('request_method', sa.String(10)),
|
||||
sa.Column('request_path', sa.String(500)),
|
||||
sa.Column('status', sa.String(20), nullable=False),
|
||||
sa.Column('status_code', sa.Integer()),
|
||||
sa.Column('resource_type', sa.String(50)),
|
||||
sa.Column('resource_id', sa.String(100)),
|
||||
sa.Column('resource_name', sa.String(200)),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('request_params', JSONB()),
|
||||
sa.Column('response_data', JSONB()),
|
||||
sa.Column('error_message', sa.Text()),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
|
||||
)
|
||||
op.create_index('idx_audit_logs_action_time', 'audit_logs', ['action_time'])
|
||||
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'])
|
||||
op.create_index('idx_audit_logs_action_type', 'audit_logs', ['action_type'])
|
||||
op.create_index('idx_audit_logs_resource_type', 'audit_logs', ['resource_type'])
|
||||
op.create_index('idx_audit_logs_status', 'audit_logs', ['status'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_audit_logs_status', 'audit_logs')
|
||||
op.drop_index('idx_audit_logs_resource_type', 'audit_logs')
|
||||
op.drop_index('idx_audit_logs_action_type', 'audit_logs')
|
||||
op.drop_index('idx_audit_logs_user_id', 'audit_logs')
|
||||
op.drop_index('idx_audit_logs_action_time', 'audit_logs')
|
||||
op.drop_table('audit_logs')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add device_replacements table
|
||||
|
||||
Revision ID: h8i9j0k1l2m3
|
||||
Revises: g7h8i9j0k1l2
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'h8i9j0k1l2m3'
|
||||
down_revision = 'g7h8i9j0k1l2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'device_replacements',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('onu_device_id', sa.BigInteger(), sa.ForeignKey('onu_devices.id'), nullable=False),
|
||||
sa.Column('old_mac', sa.String(17), nullable=False),
|
||||
sa.Column('new_mac', sa.String(17), nullable=False),
|
||||
sa.Column('reason', sa.Text()),
|
||||
sa.Column('operator_id', sa.String(100)),
|
||||
sa.Column('operator_name', sa.String(100)),
|
||||
sa.Column('replaced_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
|
||||
)
|
||||
op.create_index('idx_device_replacements_onu_device_id', 'device_replacements', ['onu_device_id'])
|
||||
op.create_index('idx_device_replacements_old_mac', 'device_replacements', ['old_mac'])
|
||||
op.create_index('idx_device_replacements_new_mac', 'device_replacements', ['new_mac'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_device_replacements_new_mac', 'device_replacements')
|
||||
op.drop_index('idx_device_replacements_old_mac', 'device_replacements')
|
||||
op.drop_index('idx_device_replacements_onu_device_id', 'device_replacements')
|
||||
op.drop_table('device_replacements')
|
||||
@@ -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}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Celery 配置"""
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from app.core.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
@@ -12,12 +13,26 @@ celery_app.conf.update(
|
||||
task_serializer='json',
|
||||
result_serializer='json',
|
||||
accept_content=['json'],
|
||||
timezone='UTC',
|
||||
timezone='Asia/Shanghai',
|
||||
enable_utc=True,
|
||||
# 使用专属队列,避免与同 Redis 上的其他 Celery 项目抢任务
|
||||
task_default_queue='h3c_onu_ms',
|
||||
beat_schedule={
|
||||
'check-devices-every-30-minutes': {
|
||||
# 每5分钟触发一次(最小间隔),任务内部根据配置的间隔自行节流
|
||||
'check-devices-scheduler': {
|
||||
'task': 'app.tasks.check_tasks.check_all_devices',
|
||||
'schedule': settings.CHECK_INTERVAL,
|
||||
'schedule': 300,
|
||||
'options': {'queue': 'h3c_onu_ms'},
|
||||
},
|
||||
'aggregate-daily-snapshot': {
|
||||
'task': 'app.tasks.check_tasks.aggregate_daily_snapshot',
|
||||
'schedule': crontab(hour=1, minute=0), # 每天凌晨 1:00
|
||||
'options': {'queue': 'h3c_onu_ms'},
|
||||
},
|
||||
'cleanup-audit-logs': {
|
||||
'task': 'app.tasks.audit_tasks.cleanup_audit_logs_task',
|
||||
'schedule': crontab(hour=2, minute=0), # 每天凌晨 2:00
|
||||
'options': {'queue': 'h3c_onu_ms'},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
+8
-1
@@ -2,7 +2,8 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision
|
||||
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit
|
||||
from app.middleware.audit_middleware import AuditMiddleware
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||
|
||||
@@ -13,6 +14,7 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(AuditMiddleware)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(devices.router)
|
||||
@@ -21,6 +23,11 @@ app.include_router(import_data.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(olt.router)
|
||||
app.include_router(provision.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(roles.router)
|
||||
app.include_router(inventory.router)
|
||||
app.include_router(settings_api.router)
|
||||
app.include_router(audit.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""审计日志中间件:拦截所有 API 请求,异步写入审计日志"""
|
||||
import json
|
||||
import time
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from app.core.security import verify_token
|
||||
|
||||
# 不记录审计日志的路径前缀
|
||||
_SKIP_PATHS = {
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/api/auth/login", # 仅获取登录 URL,无用户身份
|
||||
"/api/auth/permissions", # 高频只读
|
||||
"/api/stats/",
|
||||
"/api/olt/regions",
|
||||
"/api/olt/new-devices",
|
||||
"/api/olt/duplicate-macs",
|
||||
}
|
||||
|
||||
# 只记录写操作 + 登录回调 + 特定查询(GET 默认跳过,以下 GET 例外)
|
||||
_ALWAYS_LOG_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
||||
_LOG_GET_PATHS = {
|
||||
"/api/auth/profile",
|
||||
}
|
||||
|
||||
|
||||
def _should_log(method: str, path: str) -> bool:
|
||||
for skip in _SKIP_PATHS:
|
||||
if path.startswith(skip):
|
||||
return False
|
||||
if method in _ALWAYS_LOG_METHODS:
|
||||
return True
|
||||
if method == "GET":
|
||||
return path in _LOG_GET_PATHS
|
||||
return False
|
||||
|
||||
|
||||
def _extract_token_payload(request: Request) -> dict:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
payload = verify_token(auth[7:])
|
||||
if payload:
|
||||
return payload
|
||||
return {}
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
method = request.method
|
||||
path = request.url.path
|
||||
|
||||
if not _should_log(method, path):
|
||||
return await call_next(request)
|
||||
|
||||
# 读取请求体(只读一次,需要重新构造)
|
||||
request_params = None
|
||||
try:
|
||||
body_bytes = await request.body()
|
||||
if body_bytes:
|
||||
try:
|
||||
request_params = json.loads(body_bytes)
|
||||
# 脱敏:移除密码字段
|
||||
if isinstance(request_params, dict):
|
||||
for k in ("password", "passwd", "secret"):
|
||||
if k in request_params:
|
||||
request_params[k] = "***"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 异步写日志(不等待)
|
||||
try:
|
||||
payload = _extract_token_payload(request)
|
||||
user_id = payload.get("sub", "anonymous")
|
||||
username = payload.get("username", "anonymous")
|
||||
user_role = payload.get("role", "")
|
||||
ip_address = ""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
ip_address = forwarded.split(",")[0].strip()
|
||||
elif request.client:
|
||||
ip_address = request.client.host
|
||||
|
||||
from app.tasks.audit_tasks import create_audit_log_task
|
||||
create_audit_log_task.delay(
|
||||
user_id=str(user_id),
|
||||
username=username,
|
||||
user_role=user_role,
|
||||
method=method,
|
||||
path=path,
|
||||
ip_address=ip_address,
|
||||
user_agent=request.headers.get("user-agent", "")[:500],
|
||||
status_code=response.status_code,
|
||||
request_params=request_params,
|
||||
response_data=None, # 不捕获响应体(性能考虑)
|
||||
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
|
||||
)
|
||||
except Exception:
|
||||
pass # 中间件异常绝不影响主响应
|
||||
|
||||
return response
|
||||
@@ -1,27 +1,96 @@
|
||||
"""权限检查中间件"""
|
||||
from fastapi import HTTPException, Depends
|
||||
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
||||
import json
|
||||
from fastapi import HTTPException, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
import redis
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_token
|
||||
from app.core.config import settings
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
'admin': ['*'],
|
||||
'area_admin': ['device.view', 'device.check'],
|
||||
'school_admin': ['device.view'],
|
||||
'user': ['device.view']
|
||||
}
|
||||
_redis_client = None
|
||||
|
||||
|
||||
def check_permission(required_permission: str):
|
||||
def permission_checker(token: str):
|
||||
def get_redis() -> redis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def get_role_permissions(role: str, db: Session) -> list:
|
||||
"""从数据库加载角色权限,结果缓存到 Redis(TTL 5分钟)"""
|
||||
if role == 'admin':
|
||||
return ['*']
|
||||
|
||||
r = get_redis()
|
||||
cache_key = f"permissions:role:{role}"
|
||||
cached = r.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT p.code FROM permissions p
|
||||
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||
WHERE rp.role = :role
|
||||
"""),
|
||||
{"role": role}
|
||||
).fetchall()
|
||||
perms = [row[0] for row in rows]
|
||||
|
||||
r.setex(cache_key, 300, json.dumps(perms))
|
||||
return perms
|
||||
|
||||
|
||||
def invalidate_role_cache(role: str) -> None:
|
||||
"""修改角色权限后清除缓存"""
|
||||
get_redis().delete(f"permissions:role:{role}")
|
||||
|
||||
|
||||
def require_permission(permission: str):
|
||||
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
||||
def dependency(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||
|
||||
role = payload.get('role', 'user')
|
||||
permissions = ROLE_PERMISSIONS.get(role, [])
|
||||
perms = get_role_permissions(role, db)
|
||||
|
||||
if '*' in permissions or required_permission in permissions:
|
||||
return payload
|
||||
if '*' not in perms and permission not in perms:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
# 附加用户的区域/学校分配信息,供数据范围过滤使用
|
||||
user_id = payload.get('sub')
|
||||
if user_id and role in ('area_admin', 'school_admin'):
|
||||
from app.models.user import User
|
||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||
if user:
|
||||
payload['assigned_area'] = user.assigned_area
|
||||
payload['assigned_school'] = user.assigned_school
|
||||
|
||||
return permission_checker
|
||||
return payload
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def get_current_user(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""仅验证登录状态,不检查具体权限"""
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||
return payload
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""审计日志数据库模型"""
|
||||
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 用户信息
|
||||
user_id = Column(String(100), nullable=False)
|
||||
username = Column(String(100), nullable=False)
|
||||
user_role = Column(String(50))
|
||||
|
||||
# 操作信息
|
||||
action_time = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||
action_type = Column(String(50), nullable=False) # auth/device/olt/user/system/inventory
|
||||
action_subtype = Column(String(50)) # create/update/delete/login/...
|
||||
|
||||
# 请求信息
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(Text)
|
||||
request_method = Column(String(10))
|
||||
request_path = Column(String(500))
|
||||
|
||||
# 操作结果
|
||||
status = Column(String(20), nullable=False) # success/failed/error
|
||||
status_code = Column(Integer)
|
||||
|
||||
# 资源信息
|
||||
resource_type = Column(String(50))
|
||||
resource_id = Column(String(100))
|
||||
resource_name = Column(String(200))
|
||||
|
||||
# 日志内容
|
||||
description = Column(Text, nullable=False)
|
||||
request_params = Column(JSONB)
|
||||
response_data = Column(JSONB)
|
||||
error_message = Column(Text)
|
||||
|
||||
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_audit_logs_action_time", "action_time"),
|
||||
Index("idx_audit_logs_user_id", "user_id"),
|
||||
Index("idx_audit_logs_action_type", "action_type"),
|
||||
Index("idx_audit_logs_resource_type", "resource_type"),
|
||||
Index("idx_audit_logs_status", "status"),
|
||||
)
|
||||
@@ -13,6 +13,7 @@ class OLTDevice(Base):
|
||||
username = Column(String(100), nullable=False)
|
||||
password = Column(Text, nullable=False)
|
||||
slot_command = Column(String(50), nullable=False)
|
||||
region = Column(String(100), index=True)
|
||||
location = Column(String(200))
|
||||
description = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
@@ -72,6 +73,18 @@ class DuplicateMac(Base):
|
||||
last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class DeviceDailySnapshot(Base):
|
||||
"""设备每日状态快照(用于趋势图,避免全量扫描历史表)"""
|
||||
__tablename__ = "device_daily_snapshots"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
snapshot_date = Column(String(10), nullable=False, index=True) # YYYY-MM-DD
|
||||
total = Column(Integer, nullable=False, default=0)
|
||||
online = Column(Integer, nullable=False, default=0)
|
||||
offline = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
|
||||
class NewDevice(Base):
|
||||
"""新发现设备(OLT 扫描到但尚未补全信息的设备)"""
|
||||
__tablename__ = "new_devices"
|
||||
@@ -80,3 +93,18 @@ class NewDevice(Base):
|
||||
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True)
|
||||
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
|
||||
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
|
||||
class DeviceReplacement(Base):
|
||||
"""设备更换记录"""
|
||||
__tablename__ = "device_replacements"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, index=True)
|
||||
old_mac = Column(String(17), nullable=False)
|
||||
new_mac = Column(String(17), nullable=False)
|
||||
reason = Column(Text)
|
||||
operator_id = Column(String(100)) # 操作人 user_id
|
||||
operator_name = Column(String(100))
|
||||
replaced_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""库存管理数据模型"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, Boolean, Numeric, Date
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class MaterialCategory(Base):
|
||||
__tablename__ = "material_categories"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
code = Column(String(50), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
materials = relationship("Material", back_populates="category")
|
||||
|
||||
|
||||
class Material(Base):
|
||||
__tablename__ = "materials"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
category_id = Column(BigInteger, ForeignKey("material_categories.id"))
|
||||
name = Column(String(200), nullable=False)
|
||||
model = Column(String(100))
|
||||
specification = Column(Text)
|
||||
brand = Column(String(100))
|
||||
unit = Column(String(20), default="个")
|
||||
safe_quantity = Column(Integer, default=0)
|
||||
notes = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
category = relationship("MaterialCategory", back_populates="materials")
|
||||
batches = relationship("InventoryBatch", back_populates="material")
|
||||
serial_devices = relationship("SerialDevice", back_populates="material")
|
||||
|
||||
|
||||
class InventoryBatch(Base):
|
||||
__tablename__ = "inventory_batches"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
material_id = Column(BigInteger, ForeignKey("materials.id"))
|
||||
batch_no = Column(String(50), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
available_quantity = Column(Integer, nullable=False, default=0)
|
||||
supplier = Column(String(200))
|
||||
purchase_date = Column(Date)
|
||||
purchase_price = Column(Numeric(10, 2))
|
||||
expiry_date = Column(Date)
|
||||
location = Column(String(100))
|
||||
status = Column(String(20), default="in_stock") # in_stock, reserved, out_of_stock
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
material = relationship("Material", back_populates="batches")
|
||||
serial_devices = relationship("SerialDevice", back_populates="batch")
|
||||
|
||||
|
||||
class SerialDevice(Base):
|
||||
__tablename__ = "serial_devices"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
material_id = Column(BigInteger, ForeignKey("materials.id"))
|
||||
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"))
|
||||
serial_no = Column(String(100), unique=True, nullable=False)
|
||||
mac_address = Column(String(17), index=True)
|
||||
asset_no = Column(String(50))
|
||||
status = Column(String(20), default="in_stock", index=True)
|
||||
# in_stock, allocated, installed, in_use, returned, repairing, scrapped
|
||||
current_location = Column(String(200))
|
||||
installed_info = Column(JSONB)
|
||||
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=True)
|
||||
notes = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
material = relationship("Material", back_populates="serial_devices")
|
||||
batch = relationship("InventoryBatch", back_populates="serial_devices")
|
||||
|
||||
|
||||
class InventoryTransaction(Base):
|
||||
__tablename__ = "inventory_transactions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
transaction_no = Column(String(50), unique=True, nullable=False)
|
||||
transaction_type = Column(String(20), nullable=False)
|
||||
# purchase_in, allocate_out, return_in, scrap_out, adjust
|
||||
material_id = Column(BigInteger, ForeignKey("materials.id"))
|
||||
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"), nullable=True)
|
||||
serial_device_id = Column(BigInteger, ForeignKey("serial_devices.id"), nullable=True)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
from_status = Column(String(20))
|
||||
to_status = Column(String(20))
|
||||
operator_id = Column(BigInteger, ForeignKey("users.id"))
|
||||
project_name = Column(String(200))
|
||||
installation_info = Column(JSONB)
|
||||
notes = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
|
||||
class InventoryCheck(Base):
|
||||
__tablename__ = "inventory_checks"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
check_no = Column(String(50), unique=True, nullable=False)
|
||||
check_date = Column(Date, nullable=False)
|
||||
checker_id = Column(BigInteger, ForeignKey("users.id"))
|
||||
material_id = Column(BigInteger, ForeignKey("materials.id"))
|
||||
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"), nullable=True)
|
||||
book_quantity = Column(Integer)
|
||||
actual_quantity = Column(Integer)
|
||||
difference = Column(Integer)
|
||||
reason = Column(Text)
|
||||
adjusted = Column(Boolean, default=False)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
@@ -0,0 +1,13 @@
|
||||
"""系统设置模型"""
|
||||
from sqlalchemy import Column, String, Text, TIMESTAMP
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
key = Column(String(100), primary_key=True)
|
||||
value = Column(Text, nullable=False)
|
||||
description = Column(Text)
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,238 @@
|
||||
"""库存管理 Pydantic 模式"""
|
||||
from typing import Optional, List, Any
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ── 物料分类 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── 物料 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class MaterialCreate(BaseModel):
|
||||
category_id: int
|
||||
name: str
|
||||
model: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
unit: str = "个"
|
||||
safe_quantity: int = 0
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class MaterialUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
safe_quantity: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class MaterialResponse(BaseModel):
|
||||
id: int
|
||||
category_id: int
|
||||
category_name: Optional[str] = None
|
||||
name: str
|
||||
model: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
unit: str
|
||||
safe_quantity: int
|
||||
notes: Optional[str] = None
|
||||
total_quantity: int = 0
|
||||
available_quantity: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MaterialListResponse(BaseModel):
|
||||
total: int
|
||||
items: List[MaterialResponse]
|
||||
|
||||
|
||||
# ── 库存批次 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class BatchCreate(BaseModel):
|
||||
material_id: int
|
||||
batch_no: str
|
||||
quantity: int
|
||||
supplier: Optional[str] = None
|
||||
purchase_date: Optional[date] = None
|
||||
purchase_price: Optional[Decimal] = None
|
||||
location: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class BatchResponse(BaseModel):
|
||||
id: int
|
||||
material_id: int
|
||||
material_name: Optional[str] = None
|
||||
batch_no: str
|
||||
quantity: int
|
||||
available_quantity: int
|
||||
supplier: Optional[str] = None
|
||||
purchase_date: Optional[date] = None
|
||||
purchase_price: Optional[Decimal] = None
|
||||
location: Optional[str] = None
|
||||
status: str
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── 序列号设备 ────────────────────────────────────────────────────────────────
|
||||
|
||||
class SerialDeviceCreate(BaseModel):
|
||||
material_id: int
|
||||
batch_id: Optional[int] = None
|
||||
serial_no: str
|
||||
mac_address: Optional[str] = None
|
||||
asset_no: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SerialDeviceResponse(BaseModel):
|
||||
id: int
|
||||
material_id: int
|
||||
material_name: Optional[str] = None
|
||||
batch_id: Optional[int] = None
|
||||
serial_no: str
|
||||
mac_address: Optional[str] = None
|
||||
asset_no: Optional[str] = None
|
||||
status: str
|
||||
current_location: Optional[str] = None
|
||||
installed_info: Optional[Any] = None
|
||||
notes: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SerialDeviceListResponse(BaseModel):
|
||||
total: int
|
||||
items: List[SerialDeviceResponse]
|
||||
|
||||
|
||||
# ── 出入库操作 ────────────────────────────────────────────────────────────────
|
||||
|
||||
class PurchaseInRequest(BaseModel):
|
||||
material_id: int
|
||||
batch_no: str
|
||||
quantity: int
|
||||
supplier: Optional[str] = None
|
||||
purchase_date: Optional[date] = None
|
||||
purchase_price: Optional[Decimal] = None
|
||||
location: Optional[str] = None
|
||||
serial_nos: Optional[List[str]] = None # 高价值设备的序列号列表
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AllocateOutRequest(BaseModel):
|
||||
serial_device_id: Optional[int] = None # 序列号设备
|
||||
batch_id: Optional[int] = None # 批次设备
|
||||
quantity: int = 1
|
||||
project_name: Optional[str] = None
|
||||
installation_info: Optional[Any] = None
|
||||
# 方案三:领用时补录设备标识(适用于批次出库场景)
|
||||
mac_address: Optional[str] = None # MAC 地址
|
||||
serial_no: Optional[str] = None # 序列号
|
||||
asset_no: Optional[str] = None # 资产编号
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ReturnInRequest(BaseModel):
|
||||
serial_device_id: int
|
||||
return_type: str # simple, repair, scrap
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
transaction_no: str
|
||||
transaction_type: str
|
||||
material_id: int
|
||||
material_name: Optional[str] = None
|
||||
quantity: int
|
||||
from_status: Optional[str] = None
|
||||
to_status: Optional[str] = None
|
||||
operator_id: Optional[int] = None
|
||||
project_name: Optional[str] = None
|
||||
installation_info: Optional[Any] = None # 包含补录的设备信息
|
||||
notes: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
total: int
|
||||
items: List[TransactionResponse]
|
||||
|
||||
|
||||
# ── 盘点 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class CheckCreate(BaseModel):
|
||||
check_date: date
|
||||
material_id: int
|
||||
batch_id: Optional[int] = None
|
||||
actual_quantity: int
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class CheckResponse(BaseModel):
|
||||
id: int
|
||||
check_no: str
|
||||
check_date: date
|
||||
material_id: int
|
||||
material_name: Optional[str] = None
|
||||
batch_id: Optional[int] = None
|
||||
book_quantity: Optional[int] = None
|
||||
actual_quantity: Optional[int] = None
|
||||
difference: Optional[int] = None
|
||||
reason: Optional[str] = None
|
||||
adjusted: bool
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CheckListResponse(BaseModel):
|
||||
total: int
|
||||
items: List[CheckResponse]
|
||||
|
||||
|
||||
# ── 统计 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class InventorySummary(BaseModel):
|
||||
total_materials: int
|
||||
total_quantity: int
|
||||
available_quantity: int
|
||||
allocated_quantity: int
|
||||
low_stock_count: int
|
||||
category_stats: List[dict]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""用户相关 Schema"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UserListItem(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
role: str
|
||||
assigned_area: Optional[str] = None
|
||||
assigned_school: Optional[str] = None
|
||||
is_active: bool
|
||||
last_login: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[UserListItem]
|
||||
|
||||
|
||||
class UserRoleUpdate(BaseModel):
|
||||
role: str # admin / area_admin / school_admin / user
|
||||
assigned_area: Optional[str] = None
|
||||
assigned_school: Optional[str] = None
|
||||
@@ -0,0 +1,183 @@
|
||||
"""审计日志服务"""
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
AUDIT_LOG_DIR = os.environ.get("AUDIT_LOG_DIR", "/app/logs/audit")
|
||||
AUDIT_LOG_RETENTION_DAYS = int(os.environ.get("AUDIT_LOG_RETENTION_DAYS", "90"))
|
||||
|
||||
# 路由分类规则:(method, path_pattern, exact, action_type, action_subtype, resource_type, description)
|
||||
# exact=True 精确匹配路径;exact=False 前缀匹配。
|
||||
# 精确匹配规则排在前,前缀匹配按从长到短排列,避免短前缀误匹配。
|
||||
_ROUTE_MAP = [
|
||||
# ── 认证 ──────────────────────────────────────────────────────
|
||||
("POST", "/api/auth/callback", True, "auth", "login", "user", "用户登录"),
|
||||
("GET", "/api/auth/profile", True, "auth", "profile", "user", "查看个人信息"),
|
||||
# ── OLT:精确路径(不含 ID 段)────────────────────────────────
|
||||
("GET", "/api/olt/devices", True, "olt", "list", "olt", "查询 OLT 设备列表"),
|
||||
("POST", "/api/olt/devices", True, "olt", "create", "olt", "新建 OLT 设备"),
|
||||
("POST", "/api/olt/import", True, "olt", "import", "olt", "批量导入 OLT 设备"),
|
||||
("POST", "/api/olt/quick-scan", True, "olt", "quick_scan", "olt", "触发快速扫描"),
|
||||
("POST", "/api/olt/loopback-detection", True, "olt", "loopback", "olt", "触发环路检测"),
|
||||
# OLT 端口操作(路径含 ID,前缀匹配;/ports/toggle 比 /devices/ 更具体,先列)
|
||||
("POST", "/api/olt/devices/", False, "olt", "port_toggle", "olt", "切换 OLT 端口状态"),
|
||||
("GET", "/api/olt/devices/", False, "olt", "port_list", "olt", "查询 OLT 端口列表"),
|
||||
("PUT", "/api/olt/devices/", False, "olt", "update", "olt", "更新 OLT 设备信息"),
|
||||
("DELETE", "/api/olt/devices/", False, "olt", "delete", "olt", "删除 OLT 设备"),
|
||||
# 重复 MAC
|
||||
("POST", "/api/olt/duplicate-macs/", False, "olt", "port_clear", "olt", "清除重复 MAC 端口占用"),
|
||||
("DELETE", "/api/olt/duplicate-macs/", False, "olt", "mac_delete", "olt", "删除重复 MAC 记录"),
|
||||
# 新发现设备
|
||||
("PUT", "/api/olt/new-devices/", False, "olt", "device_fill", "olt", "补全新发现设备信息"),
|
||||
("DELETE", "/api/olt/new-devices/", False, "olt", "device_ignore", "olt", "忽略新发现设备"),
|
||||
# ── ONU 设备 ──────────────────────────────────────────────────
|
||||
("POST", "/api/check/status", True, "system", "scan_trigger", "device", "手动触发全量状态扫描"),
|
||||
("POST", "/api/import/upload", True, "device", "import", "device", "批量导入 ONU 设备"),
|
||||
("DELETE", "/api/devices/status/all", True, "device", "status_clear", "device", "清除所有设备状态"),
|
||||
("POST", "/api/devices/", False, "device", "refresh", "device", "刷新单台设备在线状态"),
|
||||
("PUT", "/api/devices/", False, "device", "update", "device", "更新 ONU 设备信息"),
|
||||
("DELETE", "/api/devices/", False, "device", "delete", "device", "删除 ONU 设备"),
|
||||
# 设备更换(路径含 /replace,需在 refresh 前匹配,通过 _classify 特殊处理)
|
||||
("POST", "/replace", False, "device", "replace", "device", "更换设备 MAC 地址"),
|
||||
# ── 用户管理 ──────────────────────────────────────────────────
|
||||
("POST", "/api/users", True, "user", "create", "user", "创建用户"),
|
||||
("PUT", "/api/users/", False, "user", "update", "user", "更新用户信息"),
|
||||
("DELETE", "/api/users/", False, "user", "delete", "user", "删除用户"),
|
||||
# ── 角色权限 ──────────────────────────────────────────────────
|
||||
("PUT", "/api/roles/", False, "user", "role_update", "role", "更新角色权限配置"),
|
||||
# ── 系统设置 ──────────────────────────────────────────────────
|
||||
("PUT", "/api/settings/check_interval", True, "system", "config_update", "system", "更新定时扫描间隔"),
|
||||
("PUT", "/api/settings/", False, "system", "config_update", "system", "更新系统配置"),
|
||||
# ── 库存管理(精确路径优先)───────────────────────────────────
|
||||
("POST", "/api/inventory/transactions/purchase", True, "inventory", "purchase", "inventory", "物料采购入库"),
|
||||
("POST", "/api/inventory/transactions/allocate", True, "inventory", "allocate", "inventory", "物料分配出库"),
|
||||
("POST", "/api/inventory/transactions/return", True, "inventory", "return", "inventory", "物料退库"),
|
||||
("POST", "/api/inventory/categories", True, "inventory", "cat_create", "inventory", "新建库存分类"),
|
||||
("POST", "/api/inventory/materials", True, "inventory", "mat_create", "inventory", "新建物料"),
|
||||
("PUT", "/api/inventory/materials/", False, "inventory", "mat_update", "inventory", "更新物料信息"),
|
||||
("DELETE", "/api/inventory/materials/", False, "inventory", "mat_delete", "inventory", "删除物料"),
|
||||
("POST", "/api/inventory/checks", True, "inventory", "check_create", "inventory", "发起库存盘点"),
|
||||
("POST", "/api/inventory/checks/", False, "inventory", "check_adjust", "inventory", "盘点差异调整"),
|
||||
("PUT", "/api/inventory/checks/", False, "inventory", "check_update", "inventory", "更新盘点记录"),
|
||||
]
|
||||
|
||||
|
||||
def _classify(method: str, path: str):
|
||||
"""根据请求方法和路径推断操作分类,精确匹配优先于前缀匹配"""
|
||||
# 第一轮:精确匹配
|
||||
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
|
||||
if exact and method == m and path == p:
|
||||
return atype, subtype, rtype, desc
|
||||
# 第二轮:后缀匹配(path_pattern 以 "/" 开头但不含 "/api",视为后缀)
|
||||
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
|
||||
if not exact and not p.startswith('/api') and method == m and path.endswith(p):
|
||||
return atype, subtype, rtype, desc
|
||||
# 第三轮:前缀匹配(规则列表已按从具体到宽泛排列)
|
||||
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
|
||||
if not exact and p.startswith('/api') and method == m and path.startswith(p):
|
||||
return atype, subtype, rtype, desc
|
||||
return "system", "request", "unknown", f"{method} {path}"
|
||||
|
||||
|
||||
def _get_ip(request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return ""
|
||||
|
||||
|
||||
def write_audit_log(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
username: str,
|
||||
user_role: str,
|
||||
method: str,
|
||||
path: str,
|
||||
ip_address: str = "",
|
||||
user_agent: str = "",
|
||||
status_code: int,
|
||||
request_params: Optional[dict] = None,
|
||||
response_data: Optional[dict] = None,
|
||||
error_message: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
resource_name: Optional[str] = None,
|
||||
):
|
||||
action_type, action_subtype, resource_type, default_desc = _classify(method, path)
|
||||
status = "success" if status_code < 400 else ("failed" if status_code < 500 else "error")
|
||||
|
||||
log = AuditLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
user_role=user_role,
|
||||
action_type=action_type,
|
||||
action_subtype=action_subtype,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent[:500] if user_agent else "",
|
||||
request_method=method,
|
||||
request_path=path,
|
||||
status=status,
|
||||
status_code=status_code,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
resource_name=resource_name,
|
||||
description=description or default_desc,
|
||||
request_params=request_params,
|
||||
response_data=response_data if status_code < 400 else None,
|
||||
error_message=error_message,
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return log.id
|
||||
|
||||
|
||||
def query_logs(
|
||||
db: Session,
|
||||
*,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
user_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
action_type: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
):
|
||||
q = db.query(AuditLog)
|
||||
filters = []
|
||||
if start_time:
|
||||
filters.append(AuditLog.action_time >= start_time)
|
||||
if end_time:
|
||||
filters.append(AuditLog.action_time <= end_time)
|
||||
if user_id:
|
||||
filters.append(AuditLog.user_id == user_id)
|
||||
if username:
|
||||
filters.append(AuditLog.username.ilike(f"%{username}%"))
|
||||
if action_type:
|
||||
filters.append(AuditLog.action_type == action_type)
|
||||
if resource_type:
|
||||
filters.append(AuditLog.resource_type == resource_type)
|
||||
if status:
|
||||
filters.append(AuditLog.status == status)
|
||||
if filters:
|
||||
q = q.filter(and_(*filters))
|
||||
|
||||
total = q.count()
|
||||
items = q.order_by(AuditLog.action_time.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return total, items
|
||||
|
||||
|
||||
def cleanup_old_logs(db: Session, retention_days: int = AUDIT_LOG_RETENTION_DAYS):
|
||||
"""清理超过保留期的日志"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=retention_days)
|
||||
deleted = db.query(AuditLog).filter(AuditLog.created_at < cutoff).delete()
|
||||
db.commit()
|
||||
return deleted
|
||||
@@ -0,0 +1,543 @@
|
||||
"""库存管理业务逻辑"""
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, text
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models.inventory import (
|
||||
MaterialCategory, Material, InventoryBatch,
|
||||
SerialDevice, InventoryTransaction, InventoryCheck
|
||||
)
|
||||
|
||||
|
||||
def _gen_no(prefix: str) -> str:
|
||||
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}"
|
||||
|
||||
|
||||
# ── 物料分类 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_categories(db: Session) -> List[MaterialCategory]:
|
||||
return db.query(MaterialCategory).order_by(MaterialCategory.id).all()
|
||||
|
||||
|
||||
def create_category(db: Session, name: str, code: str, description: Optional[str]) -> MaterialCategory:
|
||||
if db.query(MaterialCategory).filter(MaterialCategory.code == code).first():
|
||||
raise HTTPException(status_code=400, detail="分类代码已存在")
|
||||
cat = MaterialCategory(name=name, code=code, description=description)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
# ── 物料 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_materials(db: Session, skip: int, limit: int, category_id: Optional[int], keyword: Optional[str]):
|
||||
q = db.query(Material)
|
||||
if category_id:
|
||||
q = q.filter(Material.category_id == category_id)
|
||||
if keyword:
|
||||
q = q.filter(Material.name.contains(keyword) | Material.model.contains(keyword))
|
||||
total = q.count()
|
||||
items = q.order_by(Material.id).offset(skip).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for m in items:
|
||||
total_qty = db.query(func.sum(InventoryBatch.quantity)).filter(
|
||||
InventoryBatch.material_id == m.id
|
||||
).scalar() or 0
|
||||
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
|
||||
InventoryBatch.material_id == m.id
|
||||
).scalar() or 0
|
||||
item = {
|
||||
"id": m.id,
|
||||
"category_id": m.category_id,
|
||||
"category_name": m.category.name if m.category else None,
|
||||
"name": m.name,
|
||||
"model": m.model,
|
||||
"specification": m.specification,
|
||||
"brand": m.brand,
|
||||
"unit": m.unit,
|
||||
"safe_quantity": m.safe_quantity,
|
||||
"notes": m.notes,
|
||||
"total_quantity": total_qty,
|
||||
"available_quantity": avail_qty,
|
||||
"created_at": m.created_at,
|
||||
}
|
||||
result.append(item)
|
||||
return {"total": total, "items": result}
|
||||
|
||||
|
||||
def create_material(db: Session, data: dict) -> Material:
|
||||
m = Material(**data)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
def update_material(db: Session, material_id: int, data: dict) -> Material:
|
||||
m = db.query(Material).filter(Material.id == material_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="物料不存在")
|
||||
for k, v in data.items():
|
||||
if v is not None:
|
||||
setattr(m, k, v)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
def delete_material(db: Session, material_id: int):
|
||||
m = db.query(Material).filter(Material.id == material_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="物料不存在")
|
||||
has_stock = db.query(InventoryBatch).filter(
|
||||
InventoryBatch.material_id == material_id,
|
||||
InventoryBatch.available_quantity > 0,
|
||||
).first()
|
||||
if has_stock:
|
||||
raise HTTPException(status_code=400, detail="该物料仍有库存,请先出库后再删除")
|
||||
db.delete(m)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── 采购入库 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def purchase_in(db: Session, data: dict, operator_id: int) -> dict:
|
||||
material_id = data["material_id"]
|
||||
m = db.query(Material).filter(Material.id == material_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="物料不存在")
|
||||
|
||||
serial_nos = data.pop("serial_nos", None) or []
|
||||
quantity = data["quantity"]
|
||||
|
||||
batch = InventoryBatch(
|
||||
material_id=material_id,
|
||||
batch_no=data["batch_no"],
|
||||
quantity=quantity,
|
||||
available_quantity=quantity,
|
||||
supplier=data.get("supplier"),
|
||||
purchase_date=data.get("purchase_date"),
|
||||
purchase_price=data.get("purchase_price"),
|
||||
location=data.get("location"),
|
||||
status="in_stock",
|
||||
)
|
||||
db.add(batch)
|
||||
db.flush()
|
||||
|
||||
# 高价值设备:逐个创建序列号记录
|
||||
for sn in serial_nos:
|
||||
sd = SerialDevice(
|
||||
material_id=material_id,
|
||||
batch_id=batch.id,
|
||||
serial_no=sn,
|
||||
status="in_stock",
|
||||
)
|
||||
db.add(sd)
|
||||
|
||||
txn = InventoryTransaction(
|
||||
transaction_no=_gen_no("PI"),
|
||||
transaction_type="purchase_in",
|
||||
material_id=material_id,
|
||||
batch_id=batch.id,
|
||||
quantity=quantity,
|
||||
from_status=None,
|
||||
to_status="in_stock",
|
||||
operator_id=operator_id,
|
||||
notes=data.get("notes"),
|
||||
)
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
return {"message": "入库成功", "batch_id": batch.id, "transaction_no": txn.transaction_no}
|
||||
|
||||
|
||||
# ── 领用出库 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def allocate_out(db: Session, data: dict, operator_id: int) -> dict:
|
||||
serial_device_id = data.get("serial_device_id")
|
||||
batch_id = data.get("batch_id")
|
||||
quantity = data.get("quantity", 1)
|
||||
|
||||
if serial_device_id:
|
||||
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
|
||||
if not sd:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
if sd.status != "in_stock":
|
||||
raise HTTPException(status_code=400, detail=f"设备当前状态为 {sd.status},无法领用")
|
||||
|
||||
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
|
||||
if batch:
|
||||
batch.available_quantity = max(0, batch.available_quantity - 1)
|
||||
|
||||
sd.status = "allocated"
|
||||
sd.installed_info = data.get("installation_info")
|
||||
|
||||
txn = InventoryTransaction(
|
||||
transaction_no=_gen_no("AO"),
|
||||
transaction_type="allocate_out",
|
||||
material_id=sd.material_id,
|
||||
batch_id=sd.batch_id,
|
||||
serial_device_id=sd.id,
|
||||
quantity=1,
|
||||
from_status="in_stock",
|
||||
to_status="allocated",
|
||||
operator_id=operator_id,
|
||||
project_name=data.get("project_name"),
|
||||
installation_info=data.get("installation_info"),
|
||||
notes=data.get("notes"),
|
||||
)
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
return {"message": "领用成功", "transaction_no": txn.transaction_no}
|
||||
|
||||
elif batch_id:
|
||||
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="批次不存在")
|
||||
if batch.available_quantity < quantity:
|
||||
raise HTTPException(status_code=400, detail="库存不足")
|
||||
|
||||
batch.available_quantity -= quantity
|
||||
|
||||
# 方案三:领用时补录设备标识,存入 installation_info
|
||||
device_info = data.get("installation_info") or {}
|
||||
if isinstance(device_info, str):
|
||||
device_info = {}
|
||||
mac = data.get("mac_address", "").strip() if data.get("mac_address") else ""
|
||||
sn = data.get("serial_no", "").strip() if data.get("serial_no") else ""
|
||||
asset = data.get("asset_no", "").strip() if data.get("asset_no") else ""
|
||||
if mac or sn or asset:
|
||||
device_info = {k: v for k, v in {"mac_address": mac, "serial_no": sn, "asset_no": asset}.items() if v}
|
||||
|
||||
txn = InventoryTransaction(
|
||||
transaction_no=_gen_no("AO"),
|
||||
transaction_type="allocate_out",
|
||||
material_id=batch.material_id,
|
||||
batch_id=batch_id,
|
||||
quantity=quantity,
|
||||
from_status="in_stock",
|
||||
to_status="allocated",
|
||||
operator_id=operator_id,
|
||||
project_name=data.get("project_name"),
|
||||
installation_info=device_info if device_info else None,
|
||||
notes=data.get("notes"),
|
||||
)
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
return {"message": "领用成功", "transaction_no": txn.transaction_no}
|
||||
|
||||
raise HTTPException(status_code=400, detail="需要指定序列号设备或批次")
|
||||
|
||||
|
||||
# ── 退库 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def return_in(db: Session, serial_device_id: int, return_type: str, notes: Optional[str], operator_id: int) -> dict:
|
||||
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
|
||||
if not sd:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
from_status = sd.status
|
||||
if return_type == "scrap":
|
||||
to_status = "scrapped"
|
||||
elif return_type == "repair":
|
||||
to_status = "repairing"
|
||||
else:
|
||||
to_status = "in_stock"
|
||||
# 归还时恢复批次可用数量
|
||||
if sd.batch_id:
|
||||
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
|
||||
if batch:
|
||||
batch.available_quantity += 1
|
||||
|
||||
sd.status = to_status
|
||||
|
||||
txn = InventoryTransaction(
|
||||
transaction_no=_gen_no("RI"),
|
||||
transaction_type="return_in" if return_type != "scrap" else "scrap_out",
|
||||
material_id=sd.material_id,
|
||||
batch_id=sd.batch_id,
|
||||
serial_device_id=sd.id,
|
||||
quantity=1,
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
operator_id=operator_id,
|
||||
notes=notes,
|
||||
)
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
return {"message": "退库成功", "transaction_no": txn.transaction_no}
|
||||
|
||||
|
||||
# ── 批次查询 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_batches_by_material(db: Session, material_id: int) -> list:
|
||||
batches = db.query(InventoryBatch).filter(
|
||||
InventoryBatch.material_id == material_id,
|
||||
InventoryBatch.available_quantity > 0,
|
||||
).order_by(InventoryBatch.id.desc()).all()
|
||||
return [
|
||||
{
|
||||
"id": b.id,
|
||||
"batch_no": b.batch_no,
|
||||
"quantity": b.quantity,
|
||||
"available_quantity": b.available_quantity,
|
||||
"supplier": b.supplier,
|
||||
"location": b.location,
|
||||
}
|
||||
for b in batches
|
||||
]
|
||||
|
||||
|
||||
# ── 序列号设备查询 ────────────────────────────────────────────────────────────
|
||||
|
||||
def get_serial_devices(db: Session, skip: int, limit: int, material_id: Optional[int], status: Optional[str], keyword: Optional[str]):
|
||||
q = db.query(SerialDevice)
|
||||
if material_id:
|
||||
q = q.filter(SerialDevice.material_id == material_id)
|
||||
if status:
|
||||
q = q.filter(SerialDevice.status == status)
|
||||
if keyword:
|
||||
q = q.filter(
|
||||
SerialDevice.serial_no.contains(keyword) |
|
||||
SerialDevice.mac_address.contains(keyword) |
|
||||
SerialDevice.asset_no.contains(keyword)
|
||||
)
|
||||
total = q.count()
|
||||
items = q.order_by(SerialDevice.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for sd in items:
|
||||
result.append({
|
||||
"id": sd.id,
|
||||
"material_id": sd.material_id,
|
||||
"material_name": sd.material.name if sd.material else None,
|
||||
"batch_id": sd.batch_id,
|
||||
"serial_no": sd.serial_no,
|
||||
"mac_address": sd.mac_address,
|
||||
"asset_no": sd.asset_no,
|
||||
"status": sd.status,
|
||||
"current_location": sd.current_location,
|
||||
"installed_info": sd.installed_info,
|
||||
"notes": sd.notes,
|
||||
"created_at": sd.created_at,
|
||||
"updated_at": sd.updated_at,
|
||||
})
|
||||
return {"total": total, "items": result}
|
||||
|
||||
|
||||
def get_transaction_detail(db: Session, transaction_id: int) -> dict:
|
||||
t = db.query(InventoryTransaction).filter(InventoryTransaction.id == transaction_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
m = db.query(Material).filter(Material.id == t.material_id).first()
|
||||
batch = db.query(InventoryBatch).filter(InventoryBatch.id == t.batch_id).first() if t.batch_id else None
|
||||
sd = db.query(SerialDevice).filter(SerialDevice.id == t.serial_device_id).first() if t.serial_device_id else None
|
||||
|
||||
return {
|
||||
"id": t.id,
|
||||
"transaction_no": t.transaction_no,
|
||||
"transaction_type": t.transaction_type,
|
||||
"material_id": t.material_id,
|
||||
"material_name": m.name if m else None,
|
||||
"material_model": m.model if m else None,
|
||||
"material_brand": m.brand if m else None,
|
||||
"quantity": t.quantity,
|
||||
"from_status": t.from_status,
|
||||
"to_status": t.to_status,
|
||||
"operator_id": t.operator_id,
|
||||
"project_name": t.project_name,
|
||||
"installation_info": t.installation_info,
|
||||
"notes": t.notes,
|
||||
"created_at": t.created_at,
|
||||
"batch": {
|
||||
"id": batch.id,
|
||||
"batch_no": batch.batch_no,
|
||||
"supplier": batch.supplier,
|
||||
"purchase_date": str(batch.purchase_date) if batch.purchase_date else None,
|
||||
"purchase_price": str(batch.purchase_price) if batch.purchase_price else None,
|
||||
"location": batch.location,
|
||||
} if batch else None,
|
||||
"serial_device": {
|
||||
"id": sd.id,
|
||||
"serial_no": sd.serial_no,
|
||||
"mac_address": sd.mac_address,
|
||||
"asset_no": sd.asset_no,
|
||||
} if sd else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def get_transactions(db: Session, skip: int, limit: int, transaction_type: Optional[str], material_id: Optional[int]):
|
||||
q = db.query(InventoryTransaction)
|
||||
if transaction_type:
|
||||
q = q.filter(InventoryTransaction.transaction_type == transaction_type)
|
||||
if material_id:
|
||||
q = q.filter(InventoryTransaction.material_id == material_id)
|
||||
total = q.count()
|
||||
items = q.order_by(InventoryTransaction.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for t in items:
|
||||
m = db.query(Material).filter(Material.id == t.material_id).first()
|
||||
result.append({
|
||||
"id": t.id,
|
||||
"transaction_no": t.transaction_no,
|
||||
"transaction_type": t.transaction_type,
|
||||
"material_id": t.material_id,
|
||||
"material_name": m.name if m else None,
|
||||
"quantity": t.quantity,
|
||||
"from_status": t.from_status,
|
||||
"to_status": t.to_status,
|
||||
"operator_id": t.operator_id,
|
||||
"project_name": t.project_name,
|
||||
"installation_info": t.installation_info,
|
||||
"notes": t.notes,
|
||||
"created_at": t.created_at,
|
||||
})
|
||||
return {"total": total, "items": result}
|
||||
|
||||
|
||||
# ── 库存总览 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_summary(db: Session) -> dict:
|
||||
total_materials = db.query(Material).count()
|
||||
total_qty = db.query(func.sum(InventoryBatch.quantity)).scalar() or 0
|
||||
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).scalar() or 0
|
||||
allocated_qty = total_qty - avail_qty
|
||||
|
||||
# 低库存物料数
|
||||
low_stock = db.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT m.id, m.safe_quantity, COALESCE(SUM(b.available_quantity), 0) AS avail
|
||||
FROM materials m
|
||||
LEFT JOIN inventory_batches b ON b.material_id = m.id
|
||||
GROUP BY m.id, m.safe_quantity
|
||||
HAVING COALESCE(SUM(b.available_quantity), 0) <= m.safe_quantity AND m.safe_quantity > 0
|
||||
) t
|
||||
""")).scalar() or 0
|
||||
|
||||
# 按分类统计
|
||||
rows = db.execute(text("""
|
||||
SELECT c.name, COUNT(m.id) AS material_count,
|
||||
COALESCE(SUM(b.available_quantity), 0) AS available
|
||||
FROM material_categories c
|
||||
LEFT JOIN materials m ON m.category_id = c.id
|
||||
LEFT JOIN inventory_batches b ON b.material_id = m.id
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY c.id
|
||||
""")).fetchall()
|
||||
|
||||
category_stats = [{"name": r[0], "material_count": r[1], "available": r[2]} for r in rows]
|
||||
|
||||
return {
|
||||
"total_materials": total_materials,
|
||||
"total_quantity": total_qty,
|
||||
"available_quantity": avail_qty,
|
||||
"allocated_quantity": allocated_qty,
|
||||
"low_stock_count": low_stock,
|
||||
"category_stats": category_stats,
|
||||
}
|
||||
|
||||
|
||||
# ── 盘点 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_check(db: Session, data: dict, checker_id: int) -> InventoryCheck:
|
||||
material_id = data["material_id"]
|
||||
batch_id = data.get("batch_id")
|
||||
|
||||
if batch_id:
|
||||
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
|
||||
book_qty = batch.available_quantity if batch else 0
|
||||
else:
|
||||
book_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
|
||||
InventoryBatch.material_id == material_id
|
||||
).scalar() or 0
|
||||
|
||||
actual_qty = data["actual_quantity"]
|
||||
diff = actual_qty - book_qty
|
||||
|
||||
check = InventoryCheck(
|
||||
check_no=_gen_no("CK"),
|
||||
check_date=data["check_date"],
|
||||
checker_id=checker_id,
|
||||
material_id=material_id,
|
||||
batch_id=batch_id,
|
||||
book_quantity=book_qty,
|
||||
actual_quantity=actual_qty,
|
||||
difference=diff,
|
||||
reason=data.get("reason"),
|
||||
adjusted=False,
|
||||
)
|
||||
db.add(check)
|
||||
db.commit()
|
||||
db.refresh(check)
|
||||
return check
|
||||
|
||||
|
||||
def adjust_check(db: Session, check_id: int, operator_id: int) -> dict:
|
||||
check = db.query(InventoryCheck).filter(InventoryCheck.id == check_id).first()
|
||||
if not check:
|
||||
raise HTTPException(status_code=404, detail="盘点记录不存在")
|
||||
if check.adjusted:
|
||||
raise HTTPException(status_code=400, detail="已调整过")
|
||||
|
||||
if check.batch_id:
|
||||
batch = db.query(InventoryBatch).filter(InventoryBatch.id == check.batch_id).first()
|
||||
if batch:
|
||||
batch.available_quantity = check.actual_quantity
|
||||
else:
|
||||
# 调整第一个批次(简化处理)
|
||||
batch = db.query(InventoryBatch).filter(
|
||||
InventoryBatch.material_id == check.material_id
|
||||
).first()
|
||||
if batch:
|
||||
batch.available_quantity = check.actual_quantity
|
||||
|
||||
txn = InventoryTransaction(
|
||||
transaction_no=_gen_no("ADJ"),
|
||||
transaction_type="adjust",
|
||||
material_id=check.material_id,
|
||||
batch_id=check.batch_id,
|
||||
quantity=abs(check.difference or 0),
|
||||
from_status="in_stock",
|
||||
to_status="in_stock",
|
||||
operator_id=operator_id,
|
||||
notes=f"盘点调整:{check.check_no},差异 {check.difference}",
|
||||
)
|
||||
db.add(txn)
|
||||
check.adjusted = True
|
||||
db.commit()
|
||||
return {"message": "调整成功"}
|
||||
|
||||
|
||||
def get_checks(db: Session, skip: int, limit: int, material_id: Optional[int]):
|
||||
q = db.query(InventoryCheck)
|
||||
if material_id:
|
||||
q = q.filter(InventoryCheck.material_id == material_id)
|
||||
total = q.count()
|
||||
items = q.order_by(InventoryCheck.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for c in items:
|
||||
m = db.query(Material).filter(Material.id == c.material_id).first()
|
||||
result.append({
|
||||
"id": c.id,
|
||||
"check_no": c.check_no,
|
||||
"check_date": c.check_date,
|
||||
"material_id": c.material_id,
|
||||
"material_name": m.name if m else None,
|
||||
"batch_id": c.batch_id,
|
||||
"book_quantity": c.book_quantity,
|
||||
"actual_quantity": c.actual_quantity,
|
||||
"difference": c.difference,
|
||||
"reason": c.reason,
|
||||
"adjusted": c.adjusted,
|
||||
"created_at": c.created_at,
|
||||
})
|
||||
return {"total": total, "items": result}
|
||||
@@ -165,14 +165,23 @@ class SSHService:
|
||||
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).lower()
|
||||
# 提取状态字段
|
||||
if 'Up' in line:
|
||||
if re.search(r'\b(up|online)\b', line.lower()):
|
||||
devices[mac] = 'online'
|
||||
elif 'Offline' in line:
|
||||
elif re.search(r'\b(offline|down)\b', line.lower()):
|
||||
devices[mac] = 'offline'
|
||||
|
||||
return devices
|
||||
|
||||
def _clean_output(self, output: str) -> str:
|
||||
"""清理终端控制字符和 More 分页标记,避免污染解析"""
|
||||
# 移除 ANSI 转义序列
|
||||
output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
|
||||
# 移除 ---- More ---- 行(含前后控制字符)
|
||||
output = re.sub(r'---- More ----[^\n]*', '', output)
|
||||
# 将独立的 \r(不跟 \n)替换为空,避免覆盖行内容
|
||||
output = re.sub(r'\r(?!\n)', '', output)
|
||||
return output
|
||||
|
||||
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
|
||||
"""增强解析:提取完整 ONU 信息
|
||||
返回: (unique_devices, duplicate_devices)
|
||||
@@ -180,6 +189,7 @@ class SSHService:
|
||||
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
|
||||
"""
|
||||
all_records: Dict[str, List[ONUInfo]] = {}
|
||||
output = self._clean_output(output)
|
||||
lines = output.split('\n')
|
||||
|
||||
current_slot = None
|
||||
@@ -227,9 +237,11 @@ class SSHService:
|
||||
|
||||
mac = mac_match.group(1).lower()
|
||||
|
||||
# 提取状态
|
||||
# 提取状态:H3C OLT 不同固件版本可能输出 Up/UP/up/Online/online
|
||||
status = 'offline'
|
||||
if 'Up' in line:
|
||||
line_lower = line.lower()
|
||||
# 检查行末状态字段(避免误匹配 "Onu" 中的字母)
|
||||
if re.search(r'\b(up|online)\b', line_lower):
|
||||
status = 'online'
|
||||
|
||||
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""审计日志 Celery 任务"""
|
||||
import traceback
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
|
||||
|
||||
@celery_app.task(queue='h3c_onu_ms', ignore_result=True)
|
||||
def create_audit_log_task(
|
||||
user_id: str,
|
||||
username: str,
|
||||
user_role: str,
|
||||
method: str,
|
||||
path: str,
|
||||
ip_address: str,
|
||||
user_agent: str,
|
||||
status_code: int,
|
||||
request_params: dict = None,
|
||||
response_data: dict = None,
|
||||
error_message: str = None,
|
||||
description: str = None,
|
||||
resource_id: str = None,
|
||||
resource_name: str = None,
|
||||
):
|
||||
"""异步写入审计日志,不阻塞主请求流程"""
|
||||
from app.services.audit_service import write_audit_log
|
||||
db = SessionLocal()
|
||||
try:
|
||||
write_audit_log(
|
||||
db,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
user_role=user_role,
|
||||
method=method,
|
||||
path=path,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status_code=status_code,
|
||||
request_params=request_params,
|
||||
response_data=response_data,
|
||||
error_message=error_message,
|
||||
description=description,
|
||||
resource_id=resource_id,
|
||||
resource_name=resource_name,
|
||||
)
|
||||
except Exception:
|
||||
pass # 审计日志失败不影响主业务
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(queue='h3c_onu_ms')
|
||||
def cleanup_audit_logs_task():
|
||||
"""清理90天前的审计日志(每天执行)"""
|
||||
from app.services.audit_service import cleanup_old_logs
|
||||
db = SessionLocal()
|
||||
try:
|
||||
deleted = cleanup_old_logs(db)
|
||||
return {'success': True, 'deleted': deleted}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,13 +1,56 @@
|
||||
"""状态检查任务"""
|
||||
import time
|
||||
import traceback
|
||||
import redis as redis_lib
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func, case
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.config import settings
|
||||
from app.services.check_service import CheckService
|
||||
|
||||
_LAST_RUN_KEY = "check_all_devices:last_run"
|
||||
_RUNNING_KEY = "check_all_devices:running"
|
||||
_INTERVAL_REDIS_KEY = "system:check_interval_seconds"
|
||||
|
||||
|
||||
def _get_redis():
|
||||
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
|
||||
def _get_check_interval(r) -> int:
|
||||
"""从 Redis 读取配置间隔,回退到 DB,再回退到默认值"""
|
||||
cached = r.get(_INTERVAL_REDIS_KEY)
|
||||
if cached:
|
||||
return int(cached)
|
||||
# 从 DB 读取并缓存
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.setting import SystemSetting
|
||||
setting = db.query(SystemSetting).filter_by(key='check_interval_seconds').first()
|
||||
interval = int(setting.value) if setting else settings.CHECK_INTERVAL
|
||||
r.set(_INTERVAL_REDIS_KEY, str(interval))
|
||||
return interval
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def check_all_devices(self):
|
||||
"""检查所有设备状态"""
|
||||
"""检查所有设备状态(支持可配置间隔,最小5分钟)"""
|
||||
r = _get_redis()
|
||||
interval = _get_check_interval(r)
|
||||
|
||||
# Redis 节流:检查距上次运行是否已超过配置间隔
|
||||
last_run = r.get(_LAST_RUN_KEY)
|
||||
now = time.time()
|
||||
if last_run and (now - float(last_run)) < interval:
|
||||
remaining = int(interval - (now - float(last_run)))
|
||||
return {'skipped': True, 'reason': f'间隔未到,还需等待 {remaining} 秒', 'interval': interval}
|
||||
|
||||
# 标记正在运行(TTL 10分钟防止异常时永久卡住)
|
||||
r.set(_RUNNING_KEY, '1', ex=600)
|
||||
|
||||
db = SessionLocal()
|
||||
self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'})
|
||||
try:
|
||||
@@ -68,5 +111,66 @@ def check_all_devices(self):
|
||||
'error': str(e),
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
finally:
|
||||
# 任务完成后记录时间、清除运行标记
|
||||
r.set(_LAST_RUN_KEY, str(time.time()))
|
||||
r.delete(_RUNNING_KEY)
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def aggregate_daily_snapshot():
|
||||
"""聚合昨日设备状态快照(每天凌晨执行)"""
|
||||
from app.models.device import DeviceStatusHistory, DeviceDailySnapshot, ONUDevice
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yesterday = (datetime.utcnow() - timedelta(days=1)).date()
|
||||
date_str = yesterday.strftime('%Y-%m-%d')
|
||||
|
||||
# 如果已存在则跳过(幂等)
|
||||
exists = db.query(DeviceDailySnapshot).filter_by(snapshot_date=date_str).first()
|
||||
if exists:
|
||||
return {'skipped': True, 'date': date_str}
|
||||
|
||||
# 昨天每台设备的最后一次检查状态
|
||||
day_start = datetime.combine(yesterday, datetime.min.time())
|
||||
day_end = datetime.combine(yesterday, datetime.max.time())
|
||||
|
||||
daily_latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
||||
)
|
||||
.filter(DeviceStatusHistory.checked_at.between(day_start, day_end))
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
row = (
|
||||
db.query(
|
||||
func.count().label("total"),
|
||||
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
|
||||
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
||||
)
|
||||
.join(
|
||||
daily_latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
|
||||
)
|
||||
.one()
|
||||
)
|
||||
|
||||
snapshot = DeviceDailySnapshot(
|
||||
snapshot_date=date_str,
|
||||
total=int(row.total or 0),
|
||||
online=int(row.online or 0),
|
||||
offline=int(row.offline or 0),
|
||||
)
|
||||
db.add(snapshot)
|
||||
db.commit()
|
||||
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Celery Worker 入口模块"""
|
||||
from app.core.celery_app import celery_app
|
||||
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
||||
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
||||
from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务
|
||||
|
||||
__all__ = ['celery_app']
|
||||
|
||||
Reference in New Issue
Block a user