From f1f8518985448cb84436b5fcc90dfb621c94d81d Mon Sep 17 00:00:00 2001 From: v6ole Date: Mon, 6 Apr 2026 00:40:08 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(auth):=20=E6=B7=BB=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=9D=83=E9=99=90=E8=8E=B7=E5=8F=96=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84JWT=E4=BB=A4=E7=89=8C=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在JWT令牌中添加用户角色信息 - 新增get_my_permissions接口用于获取当前用户权限码列表 - 重构认证回调逻辑,增加错误日志记录 - 更新用户信息获取接口使用Authorization头验证 ``` --- .claude/skills/create-ex | 1 + CLAUDE.md | 96 +- .../a1b2c3d4e5f6_rebuild_users_permissions.py | 107 ++ ...5f6a7_add_olt_port_discover_permissions.py | 28 + .../c3d4e5f6a7b8_add_inventory_tables.py | 192 +++ .../versions/d4e5f6a7b8c9_add_olt_region.py | 26 + ...e5f6a7b8c9d0_add_device_daily_snapshots.py | 32 + .../f6a7b8c9d0e1_add_system_settings.py | 33 + .../versions/g7h8i9j0k1l2_add_audit_logs.py | 55 + .../h8i9j0k1l2m3_add_device_replacements.py | 38 + backend/app/api/v1/audit.py | 156 ++ backend/app/api/v1/auth.py | 43 +- backend/app/api/v1/check.py | 20 +- backend/app/api/v1/devices.py | 175 +- backend/app/api/v1/import_data.py | 6 +- backend/app/api/v1/inventory.py | 196 +++ backend/app/api/v1/olt.py | 136 +- backend/app/api/v1/provision.py | 7 +- backend/app/api/v1/roles.py | 110 ++ backend/app/api/v1/settings.py | 76 + backend/app/api/v1/stats.py | 162 +- backend/app/api/v1/users.py | 81 + backend/app/core/celery_app.py | 21 +- backend/app/main.py | 9 +- backend/app/middleware/audit_middleware.py | 109 ++ .../app/middleware/permission_middleware.py | 101 +- backend/app/models/audit_log.py | 52 + backend/app/models/device.py | 28 + backend/app/models/inventory.py | 117 ++ backend/app/models/setting.py | 13 + backend/app/schemas/inventory.py | 238 +++ backend/app/schemas/user.py | 30 + backend/app/services/audit_service.py | 183 ++ backend/app/services/inventory_service.py | 543 ++++++ backend/app/services/ssh_service.py | 22 +- backend/app/tasks/audit_tasks.py | 62 + backend/app/tasks/check_tasks.py | 106 +- backend/celery_worker.py | 3 +- deploy/docker-compose.yml | 8 +- docker-compose.yml | 27 +- frontend/index.html | 15 +- frontend/public/icon-180.png | Bin 0 -> 1312 bytes frontend/public/icon-192.png | Bin 0 -> 1429 bytes frontend/public/icon-512.png | Bin 0 -> 4416 bytes frontend/public/manifest.json | 14 + frontend/src/api/audit.js | 6 + frontend/src/api/inventory.js | 34 + frontend/src/api/role.js | 6 + frontend/src/api/settings.js | 4 + frontend/src/api/user.js | 5 + frontend/src/components/Layout.vue | 565 +++++- frontend/src/composables/useMobile.js | 16 + frontend/src/composables/usePermission.js | 8 + frontend/src/main.js | 38 +- frontend/src/router/index.js | 66 +- frontend/src/stores/auth.js | 52 +- frontend/src/styles/theme.css | 92 + frontend/src/utils/message.js | 31 + frontend/src/views/AuditLog.vue | 248 +++ frontend/src/views/Dashboard.vue | 71 +- frontend/src/views/DeviceList.vue | 639 ++++++- frontend/src/views/ImportData.vue | 9 +- frontend/src/views/Inventory.vue | 1528 +++++++++++++++++ frontend/src/views/OltManage.vue | 292 +++- frontend/src/views/RolePermissions.vue | 214 +++ frontend/src/views/Settings.vue | 352 ++++ frontend/src/views/UserManage.vue | 454 +++++ frontend/vite.config.js | 2 +- 审计日志系统设计方案.md | 535 ++++++ 库存管理功能设计方案.md | 532 ++++++ 移动端设计思路.md | 317 ++++ 71 files changed, 9402 insertions(+), 191 deletions(-) create mode 160000 .claude/skills/create-ex create mode 100644 backend/alembic/versions/a1b2c3d4e5f6_rebuild_users_permissions.py create mode 100644 backend/alembic/versions/b2c3d4e5f6a7_add_olt_port_discover_permissions.py create mode 100644 backend/alembic/versions/c3d4e5f6a7b8_add_inventory_tables.py create mode 100644 backend/alembic/versions/d4e5f6a7b8c9_add_olt_region.py create mode 100644 backend/alembic/versions/e5f6a7b8c9d0_add_device_daily_snapshots.py create mode 100644 backend/alembic/versions/f6a7b8c9d0e1_add_system_settings.py create mode 100644 backend/alembic/versions/g7h8i9j0k1l2_add_audit_logs.py create mode 100644 backend/alembic/versions/h8i9j0k1l2m3_add_device_replacements.py create mode 100644 backend/app/api/v1/audit.py create mode 100644 backend/app/api/v1/inventory.py create mode 100644 backend/app/api/v1/roles.py create mode 100644 backend/app/api/v1/settings.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/middleware/audit_middleware.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/models/inventory.py create mode 100644 backend/app/models/setting.py create mode 100644 backend/app/schemas/inventory.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/audit_service.py create mode 100644 backend/app/services/inventory_service.py create mode 100644 backend/app/tasks/audit_tasks.py create mode 100644 frontend/public/icon-180.png create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png create mode 100644 frontend/public/manifest.json create mode 100644 frontend/src/api/audit.js create mode 100644 frontend/src/api/inventory.js create mode 100644 frontend/src/api/role.js create mode 100644 frontend/src/api/settings.js create mode 100644 frontend/src/api/user.js create mode 100644 frontend/src/composables/useMobile.js create mode 100644 frontend/src/composables/usePermission.js create mode 100644 frontend/src/utils/message.js create mode 100644 frontend/src/views/AuditLog.vue create mode 100644 frontend/src/views/Inventory.vue create mode 100644 frontend/src/views/RolePermissions.vue create mode 100644 frontend/src/views/Settings.vue create mode 100644 frontend/src/views/UserManage.vue create mode 100644 审计日志系统设计方案.md create mode 100644 库存管理功能设计方案.md create mode 100644 移动端设计思路.md diff --git a/.claude/skills/create-ex b/.claude/skills/create-ex new file mode 160000 index 0000000..54d0dce --- /dev/null +++ b/.claude/skills/create-ex @@ -0,0 +1 @@ +Subproject commit 54d0dceab6eeab55c68267aa4b9e0c68bd9e10a6 diff --git a/CLAUDE.md b/CLAUDE.md index ccd96fa..a7c848f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,17 +4,26 @@ 这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。 -**当前版本**: v0.5.0 -**开发状态**: 核心功能已完成 +**当前版本**: v0.7.0 +**开发状态**: 核心功能已完成,权限与运维功能完善中 **已实现功能**: -- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页) +- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理) - ✅ Excel 数据导入和批量管理 -- ✅ 定时自动状态检查(每30分钟)+ 手动触发 +- ✅ 定时自动状态检查(可配置间隔,最小5分钟)+ 手动触发 - ✅ Casdoor 统一认证和 JWT 令牌管理 - ✅ 设备管理(列表、详情、筛选、分页) -- ✅ 统计仪表板(总数、在线、离线) -- ✅ 权限管理基础框架(RBAC) +- ✅ 统计仪表板(总数、在线、离线)+ 区域分布饼图 + 7天趋势折线图 +- ✅ 完整 RBAC 权限管理(角色、权限、用户管理页面) +- ✅ OLT 管理(增删改查、批量导入、区域动态同步) +- ✅ OLT 端口管理(查看端口状态、开关端口) +- ✅ 重复 MAC 检测与清除 +- ✅ 新设备发现与信息补全 +- ✅ 快速扫描(多线程并发) +- ✅ 环路检测 +- ✅ 系统设置(管理员可配置检查间隔,显示下次扫描时间/扫描中状态) +- ✅ 库存管理模块(物料、序列号设备、出入库、盘点) +- ✅ 每日状态快照(凌晨1点聚合,用于趋势图性能优化) **技术栈**: - 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko @@ -173,6 +182,79 @@ --- +## 部署陷阱与经验教训 + +### 修改代码后必须重新构建镜像 + +**问题**:修改了宿主机上的源码后,直接 `docker compose up -d` 或 `docker compose restart` **不会**让容器使用新代码。容器运行的是构建时打包进镜像的旧代码。 + +**正确流程**: +```bash +# 1. 重新构建镜像(必须加 --no-cache 确保不用旧层) +docker compose build --no-cache backend # 或 frontend,或两者 + +# 2. 重新创建容器(必须用 rm + up,或 up --force-recreate) +docker compose rm -f backend +docker compose up -d backend + +# 3. 如有数据库模型变更,执行迁移 +docker compose exec backend alembic upgrade head +``` + +**不要用**: +- `docker compose restart`:只重启进程,不更新镜像 +- `docker compose up -d`(无 build):容器配置变了会重建,但镜像不变 + +### 端口被占用导致容器启动但无端口映射 + +**问题**:如果宿主机端口已被其他容器占用,新容器会启动成功(`docker compose up` 不报错),但端口映射为空 `{}`,外部无法访问。 + +**排查方法**: +```bash +# 检查端口映射是否正常 +docker inspect --format '{{json .NetworkSettings.Ports}}' + +# 查看谁占用了端口 +docker ps | grep +``` + +**解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`。 + +### frontend 容器必须配置 VITE_API_PROXY_TARGET + +**问题**:根目录 `docker-compose.yml` 的 frontend 服务如果没有配置 `VITE_API_PROXY_TARGET`,Vite 代理会默认打到 `http://localhost:8001`,导致所有 `/api` 请求 500 或无法到达后端。 + +**必须在 docker-compose.yml 中配置**: +```yaml +frontend: + build: ./frontend + ports: + - "5173:5173" + environment: + - VITE_API_PROXY_TARGET=http://backend:8000 +``` + +### 新增数据库模型后必须执行迁移 + +**问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot`、`SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。 + +**每次新增模型的完整流程**: +```bash +# 1. 创建迁移文件(在宿主机或容器内) +docker compose exec backend alembic revision --autogenerate -m "描述" + +# 2. 重建镜像 +docker compose build --no-cache backend + +# 3. 重启容器 +docker compose rm -f backend && docker compose up -d backend + +# 4. 执行迁移 +docker compose exec backend alembic upgrade head +``` + +--- + ## 项目结构 ``` @@ -281,7 +363,7 @@ docker-compose exec backend python scripts/init_db.py ```bash # 创建迁移 alembic revision --autogenerate -m "描述" - +/home/v6ole/pyproject/H3ConuMS2/CLAUDE.md # 执行迁移 alembic upgrade head diff --git a/backend/alembic/versions/a1b2c3d4e5f6_rebuild_users_permissions.py b/backend/alembic/versions/a1b2c3d4e5f6_rebuild_users_permissions.py new file mode 100644 index 0000000..8239899 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_rebuild_users_permissions.py @@ -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') diff --git a/backend/alembic/versions/b2c3d4e5f6a7_add_olt_port_discover_permissions.py b/backend/alembic/versions/b2c3d4e5f6a7_add_olt_port_discover_permissions.py new file mode 100644 index 0000000..f7fc541 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_add_olt_port_discover_permissions.py @@ -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')") diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_inventory_tables.py b/backend/alembic/versions/c3d4e5f6a7b8_add_inventory_tables.py new file mode 100644 index 0000000..e9e7993 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_inventory_tables.py @@ -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'") diff --git a/backend/alembic/versions/d4e5f6a7b8c9_add_olt_region.py b/backend/alembic/versions/d4e5f6a7b8c9_add_olt_region.py new file mode 100644 index 0000000..0b3426a --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_add_olt_region.py @@ -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') diff --git a/backend/alembic/versions/e5f6a7b8c9d0_add_device_daily_snapshots.py b/backend/alembic/versions/e5f6a7b8c9d0_add_device_daily_snapshots.py new file mode 100644 index 0000000..f5d8596 --- /dev/null +++ b/backend/alembic/versions/e5f6a7b8c9d0_add_device_daily_snapshots.py @@ -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') diff --git a/backend/alembic/versions/f6a7b8c9d0e1_add_system_settings.py b/backend/alembic/versions/f6a7b8c9d0e1_add_system_settings.py new file mode 100644 index 0000000..3f957af --- /dev/null +++ b/backend/alembic/versions/f6a7b8c9d0e1_add_system_settings.py @@ -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') diff --git a/backend/alembic/versions/g7h8i9j0k1l2_add_audit_logs.py b/backend/alembic/versions/g7h8i9j0k1l2_add_audit_logs.py new file mode 100644 index 0000000..b81689a --- /dev/null +++ b/backend/alembic/versions/g7h8i9j0k1l2_add_audit_logs.py @@ -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') diff --git a/backend/alembic/versions/h8i9j0k1l2m3_add_device_replacements.py b/backend/alembic/versions/h8i9j0k1l2m3_add_device_replacements.py new file mode 100644 index 0000000..e5fa2bf --- /dev/null +++ b/backend/alembic/versions/h8i9j0k1l2m3_add_device_replacements.py @@ -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') diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py new file mode 100644 index 0000000..4413062 --- /dev/null +++ b/backend/app/api/v1/audit.py @@ -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 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 1f9f519..82ef0fa 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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="无效的令牌") diff --git a/backend/app/api/v1/check.py b/backend/app/api/v1/check.py index 5eb20a1..2e55540 100644 --- a/backend/app/api/v1/check.py +++ b/backend/app/api/v1/check.py @@ -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) diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py index 976f092..80595a0 100644 --- a/backend/app/api/v1/devices.py +++ b/backend/app/api/v1/devices.py @@ -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 + ] + + diff --git a/backend/app/api/v1/import_data.py b/backend/app/api/v1/import_data.py index 2a852dd..a1d755e 100644 --- a/backend/app/api/v1/import_data.py +++ b/backend/app/api/v1/import_data.py @@ -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}" diff --git a/backend/app/api/v1/inventory.py b/backend/app/api/v1/inventory.py new file mode 100644 index 0000000..cbe9a55 --- /dev/null +++ b/backend/app/api/v1/inventory.py @@ -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))) diff --git a/backend/app/api/v1/olt.py b/backend/app/api/v1/olt.py index eb55b21..85f2896 100644 --- a/backend/app/api/v1/olt.py +++ b/backend/app/api/v1/olt.py @@ -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"): diff --git a/backend/app/api/v1/provision.py b/backend/app/api/v1/provision.py index a9c4459..7ff8d47 100644 --- a/backend/app/api/v1/provision.py +++ b/backend/app/api/v1/provision.py @@ -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) diff --git a/backend/app/api/v1/roles.py b/backend/app/api/v1/roles.py new file mode 100644 index 0000000..34210e2 --- /dev/null +++ b/backend/app/api/v1/roles.py @@ -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": "权限更新成功"} diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py new file mode 100644 index 0000000..c4f11b2 --- /dev/null +++ b/backend/app/api/v1/settings.py @@ -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} diff --git a/backend/app/api/v1/stats.py b/backend/app/api/v1/stats.py index 9ebd374..5b55ed5 100644 --- a/backend/app/api/v1/stats.py +++ b/backend/app/api/v1/stats.py @@ -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 diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..4e33658 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -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} diff --git a/backend/app/core/celery_app.py b/backend/app/core/celery_app.py index e728dab..771439b 100644 --- a/backend/app/core/celery_app.py +++ b/backend/app/core/celery_app.py @@ -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'}, }, }, ) diff --git a/backend/app/main.py b/backend/app/main.py index c35e658..f11842f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/middleware/audit_middleware.py b/backend/app/middleware/audit_middleware.py new file mode 100644 index 0000000..9ab69da --- /dev/null +++ b/backend/app/middleware/audit_middleware.py @@ -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 diff --git a/backend/app/middleware/permission_middleware.py b/backend/app/middleware/permission_middleware.py index 78e38d4..8bbb7bc 100644 --- a/backend/app/middleware/permission_middleware.py +++ b/backend/app/middleware/permission_middleware.py @@ -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 diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..232b56e --- /dev/null +++ b/backend/app/models/audit_log.py @@ -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"), + ) diff --git a/backend/app/models/device.py b/backend/app/models/device.py index a8ec214..4503cda 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -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()) diff --git a/backend/app/models/inventory.py b/backend/app/models/inventory.py new file mode 100644 index 0000000..4878774 --- /dev/null +++ b/backend/app/models/inventory.py @@ -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()) diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py new file mode 100644 index 0000000..3e59cf6 --- /dev/null +++ b/backend/app/models/setting.py @@ -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()) diff --git a/backend/app/schemas/inventory.py b/backend/app/schemas/inventory.py new file mode 100644 index 0000000..945ae05 --- /dev/null +++ b/backend/app/schemas/inventory.py @@ -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] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..5327aa2 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..b6145f7 --- /dev/null +++ b/backend/app/services/audit_service.py @@ -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 diff --git a/backend/app/services/inventory_service.py b/backend/app/services/inventory_service.py new file mode 100644 index 0000000..77734aa --- /dev/null +++ b/backend/app/services/inventory_service.py @@ -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} diff --git a/backend/app/services/ssh_service.py b/backend/app/services/ssh_service.py index f7a0dc3..0a28e6b 100644 --- a/backend/app/services/ssh_service.py +++ b/backend/app/services/ssh_service.py @@ -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" diff --git a/backend/app/tasks/audit_tasks.py b/backend/app/tasks/audit_tasks.py new file mode 100644 index 0000000..c73c08f --- /dev/null +++ b/backend/app/tasks/audit_tasks.py @@ -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() diff --git a/backend/app/tasks/check_tasks.py b/backend/app/tasks/check_tasks.py index 4ddde08..e259a5b 100644 --- a/backend/app/tasks/check_tasks.py +++ b/backend/app/tasks/check_tasks.py @@ -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() diff --git a/backend/celery_worker.py b/backend/celery_worker.py index 7036d9c..604449f 100644 --- a/backend/celery_worker.py +++ b/backend/celery_worker.py @@ -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'] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9f14a68..bc3426b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -23,7 +23,7 @@ services: - ../backend/static:/app/static restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] interval: 30s timeout: 10s retries: 3 @@ -71,7 +71,6 @@ services: - SECRET_KEY=${SECRET_KEY} volumes: - ../backend/logs:/app/logs - - ../backend/celerybeat-schedule:/app/celerybeat-schedule depends_on: - backend restart: unless-stopped @@ -83,9 +82,10 @@ services: dockerfile: Dockerfile container_name: h3c-onu-ms-frontend ports: - - "8081:80" + - "5173:5173" environment: - VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001} + - VITE_API_PROXY_TARGET=http://backend:8000 - VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT} - VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID} - VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME} @@ -94,7 +94,7 @@ services: - backend restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:80"] + test: ["CMD", "wget", "-qO-", "http://localhost:5173"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index b65918c..1b27a8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: backend: build: ./backend @@ -10,23 +8,42 @@ services: volumes: - ./backend/logs:/app/logs restart: unless-stopped + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 40s celery-worker: build: ./backend - command: celery -A app.core.celery_app worker --loglevel=info + command: celery -A celery_worker.celery_app worker --loglevel=info -Q h3c_onu_ms env_file: - ./backend/.env + volumes: + - ./backend/logs:/app/logs restart: unless-stopped + depends_on: + backend: + condition: service_healthy celery-beat: build: ./backend - command: celery -A app.core.celery_app beat --loglevel=info + command: celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule env_file: - ./backend/.env restart: unless-stopped + depends_on: + backend: + condition: service_healthy frontend: build: ./frontend ports: - - "5173:5173" + - "18002:5173" + environment: + - VITE_API_PROXY_TARGET=http://backend:8000 restart: unless-stopped + depends_on: + backend: + condition: service_healthy diff --git a/frontend/index.html b/frontend/index.html index ef19410..a8a888c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,21 @@ - + H3C ONU设备管理系统 + + + + + + + + + + + + +
diff --git a/frontend/public/icon-180.png b/frontend/public/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..83bccbcfef28e6f0a60e47b09ec3ddb397191ebc GIT binary patch literal 1312 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6V0rH8;uumf=k48tfsdmE+5#QV z-qRKnW|%0z%_zXhD8M~YfPp)#uOV{JCy~eL&8m0zO{$+O+^}@ruZ;Mc^JdLXTv}HC zeZKd7=I3wi@8wO~*B%^J82sq$hPD&pyBP)3qqW|x6Bqqx_O)mC>DHDr246ULe_?WZ zHs#a?@3@E)F^iPm+`gu-k){`V(d+Vpqh})4&d%KQsnv&Dw@+cS`IZ@(OI^J;H;Eaq zJ)4_VbRlWksX5!!Sas7@`R3-70J+mO*Y;9ILlH}TERf5QWpZ~1? z(DinX*eA{6?WIvCmWSlLeysbpIAhTt;Vo}%?#{8?zF}_r!nAXb)4a{UgeBR{FnSLp zSIoOUv00w1=k5)Jx1W|t{ImAauT?fUzA#Z59R2qfM9W*3 z@%!uR#=np10Y}L0PjS~)eZ3QZd+Wl^AgM3cwtbmW35=N4&;P2w=<>JTCiE4U{)9@8 z&N+V8sz~7J6SsSJ@79OKTB!re^zA&eB~>7<#G`@exARXxiiaK zKIZebcVF8p&)(i;^`!WGbvG+-@f_}7ZzdjoZymAv{jERWcyzO_JzK6;U!x&nnqBDG zZP%`L%GL4qC7rjI*!1sRSd^xjd`r6YiGb~(4i6`0 zy|?=LfI+4+sgR+|h0#LH^$|nO2T#EY{^*>_$dixKcd=Kz=RRN%V$YURma59a-8E2>UgoZHL)d=}e=)`Ra)b0l&%-xrrXw1Qz# zdwn;MDR@YXeZRg@OSIsU{Vc+5`=|VRt-@2U`SbN3{e}~}?NfhPZ@uO%`Ooi4RsPws zTYL`E|IXbhz1+5c^47fn{0dv@nL4WeEWG#EI{3Q(-rwa)*Y3|REN58wm-oQ#`ZXJ` zH`aZB@A97M9fOlG!$Qf13*VOhxVpSz*UWNufmw_kXBk@37@ThZX3|-8`10xL)(sbU z95UDxOqdap5N)&(=H`D;U}NhppUitKuKn=GFJh&7*+R9lcNkly2{F`)&)8M>OK-x) zI-qx!vO3&leYS4)F7A}I)}`zVTm!QAZs9PxKKmbsOXq@bR+@q->x;`Ps(zlbc~E}+ z^v}gtH!ekp$(Sz@)?Gyg-IY|5qdbNJC!Jnm1)*G8L zocd6nwRXDEpSfqgE@MTGFQAoCUJO@u*QdYwwX*!b-?3kJ8C%5U82-MyFIg@70x0sO zv;Zk$Fq{R6FDeRuyMF$A?CZ%BqIT5ndk+kEU}}Jv{%z@>O392`rJ!Yb&w!4EhyxSV zcZR^4`CmWC0wZp(D?H+4AQAVE^FXXa^!524r<61N(laRi`z2N(`R_g!?zj6H7RfcQ z*nQwmX>fUCN9p}pZ?DKUh&itPdP(U-%=@zpO7U%)f>+|yRhw3n-kbe$cXJ&uF1suy zzF#-}bM_a*O{t)`v|rReQP=9jMaC(UgdH_+b1Xc1ZdQQ5Zn^D^Y@UZuDyxK@0z zcz=F@Q?*3Hf7xqMyfg1QS~m!0wLu5`WDH?MJ{K;UTpZ~u|!HKe-*IU*dd*j7;;8BSXuiNfVU2PUiGhG(CCpdfl0bRC@zTOf)_8pyj!Pae~p0~`m=sM>A Ydmi6g7UC`pEN&S*UHx3vIVCg!0JObxod5s; literal 0 HcmV?d00001 diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..9da38724bc1aac28b03f1984da7e15ec892b34ce GIT binary patch literal 4416 zcmeHLYgiL!7Cw`NfZT#mHGm~xaZv;bprBNeC{|EWR6s=pf+!KWcCE|JkWnlw3We$_ zC`v$z2y#;tC<=-Ah!zo|2*@?DRk=fys|iVVg0;0j_V4bW{WF>8d}rR7?_A#Veo5M~ z-C0ZBKpgrz$@`BFZ)bH<|%OMF8$W^Hi@BVn7 zv?E@pD&-q*zCiW*HqAy>P9Sv#kt`vUlrt8QXMwGU>+{Oc(~LuJs+~x+csxIz!VK9U zdfL}U$yR?rVIFUi*uPtP&=wAE;c5L-_DU-bt}VAGEb&9ga#;LR)2Zu&n`LK_46^|{ z<6l)*&meo3m=F>;D$E&tnMd6)*#UHG8Bk#@(bRu`I$**|3uULwY++Uz!UGC=CuNOy zf;u{%XADQkq4k>I=2>^9;~1e|-P7%t6Zv9QX82c)StWSpDJwu}leGA1Ewp!$5h|Zt zccwZv@!9wRJhh}Z&MN!H99&^c=FT1hBV0p+k(Cwbxd;)ooW!LSr(g7>L5!CRtm*gv z$9ZkkXrT^lFQ7eWATlPu$D_gK&9}}XJEHFcGpW$Fct3dfEQ2b~haJ1k!F;6-6yyYx~>^!7;LXaQqfJ?62r6>Rt+(9LQjm zUi>*)=~LEdRNgWMz{uW)SNe1Z4(V5W%{|SvWDWcc!2->9sJK)Rr-~J8u@Pj+5vGrK z<8FHZox`mlo*+Tj-q`w*g^)mpkl?3YLkj>1lH2u)qF1 z9L5Zs;NK0?ZQPvdbRm5#ONe7S5oxL?nQQS}{Ce@uBy3AU+likyXXsI{r7Dw&yiDx9%5%Mu^ zo>9~A6Iy2Yx9}59mKw9nz2&gf6VMU$BxQ^HM#9h>DH4KlrO724$!mg zz6RY=1(I#HHSmrhsuvU6xx~pz`n+Y~f{t{`Q+4#Ge1)&JmyM^8tLgG#tP(W&Z{hif=B z&Uvim(&=v3NIbv^!Y3amFs)IjqJ;dwyB|MF2@NP?49AypfZQUq?fr!3qAW7SM|;=1 zqQBrM*NhPinC5nlhduvA;>9tH7IE|DHu^gzMwfpW1S|!%elU9G{bcH2Ui`V!+?V{{ z@JG=8%kg-q%KzB)`0m0hfy~9$S^r?niz;#5JTW47~+%++Med3o| zj6ZZX&lQBY)V#fb>U>8w=x{#kWohZhN1O{CJ?}5~oG!>Wk706a=VR_JFq=2nvcT0{ zCL0(y-7wrdP2kUKC3)FQ-m?1gmd8QAIMcbJUz3}jZ)r18L2(S-nIwz41XbCU%3iD{ zVZF$tWSK!p|D^bkrm};rEIT;q^HBX5`l*4IWzr@SSBsruo!S8sYG>S0a=Do_ z)Cei#A5OGcol@PrxMBD{Ez=DwRt>&#)xuaM(boGG@4|-(@tYxT8|CUAD|!~^j$AX@ zf8su;;Yn&=crhIxK%EKyjVRJiO0*l=+T*Mp+Qy{xG->g-bQ8-w21WFTp|Dkdc3KZs z=;8JUh`DBjs(O)MD!fc1i}(V+?V1tH{%|K4cF}oF6}OwI{hd4`)xje2*bn)=rT&cb zf7Kvwm*7pKKfIeQ#xw|plOQ7FTKQt=HmjNT7QgusQnjkYD8cHh~3IqRi2# zZK7>#S@T;xuub`s;fE`u34hRgAN87wbkqTfc(^Q)${u7Z)}9LbMSj;6_6wx1o{(dD z61Dxz%Sg?7z?z8Zx4G6kmFtwgW%yCc$GDqKOx^w_!FMVz-_rb+6rnx=+dm z((-JibDbkz_nIM(jNFFW zV^_AM4trzvA3r5|84U{WIe+hcyO-CS`@DQYW=H`@`-5ITx&7bpaoqfm;z8rhY4b#Y zsqy#HJ~O43`p+q$xwQ-l@fC7o;7@7W3O}Exb2C%935gZVE(J&)iE6uxVDtCW)SFld zvqqSGU`+on167a+;#dIc1^4ov&jGW~A);waUuyOR#>{Fd`5O~yNLXu(g0_w(Yx$hN zn}}#Td@dQ>rPJ3V&X`N>WRB+STj8aIx%K>D+c`;5Z2-$F!L!8O|A!;e)??<5n_re5 z4XpemWnv3Yx)+YNoXR(su(-7s7r^pT1hONQVh81Uu$4lGM|bquc~SPEcNko%Rj$W9 zK3kB^#@guWzt}$?-2HLeqGaE!ipPSkIM?XTtE8#lg3jQK2Uhb}O-5p^={ud{l&*eu z>eX}o5u)g{FUhZ7AeC=*I9~8bem4Zuc|AB+cLmnv28V=Lmz#tNdu%d$pm9}$zbj=^ zS9nmOb!bQUc=s2OR0%DsdUIU2DH+5j)}@unzG*V*LxIgHZu6P#_NS|uAiFEvh ze*A-kRZM!Gn@(vK?&A>asS*+W&6`K#rET03)}|zRY}o4_=r(yL2xJ#XmwgZfI!-+s z8I4Ql#%V-|?9jYjs*2@d_`MAxo?`ml?hl7(^TnIgB2w;ivUEfS@rqUhUa}R~nG)Af zC(`xsi^&qo`FUuNE^<`u>Fzp?0(t$WZzy?4AHpeTN zGkYN4H0~K(zTlv@gwr1oh6kHGZFzh@MV_GpkJD%~Bu0 za9;QtX95EIc*Qym+jt%5KXadRMP(mD4PFF_n^`$wrbIYGod^$jHflRFB>7xx{m|xKoksnv3CxZ-CDLFjLGk3qThKP$fJc>q7nlU`rpBdRZvw?t69q>GD zzm5JUq5sMWzjUi>PIYj-K6@@~ literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..b2e3cfa --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "H3C ONU 设备管理", + "short_name": "ONU管理", + "description": "H3C OLT/ONU 设备监控管理系统", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0a0e1a", + "theme_color": "#0a0e1a", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/frontend/src/api/audit.js b/frontend/src/api/audit.js new file mode 100644 index 0000000..ddbceff --- /dev/null +++ b/frontend/src/api/audit.js @@ -0,0 +1,6 @@ +import request from '../utils/request' + +export const getAuditLogs = (params) => request.get('/audit/logs', { params }) +export const getAuditLogDetail = (id) => request.get(`/audit/logs/${id}`) +export const getAuditStats = (days = 7) => request.get('/audit/stats', { params: { days } }) +export const exportAuditLogs = (params) => request.get('/audit/logs/export/csv', { params, responseType: 'blob' }) diff --git a/frontend/src/api/inventory.js b/frontend/src/api/inventory.js new file mode 100644 index 0000000..3922427 --- /dev/null +++ b/frontend/src/api/inventory.js @@ -0,0 +1,34 @@ +import request from '../utils/request' + +export const inventoryApi = { + // 分类 + getCategories: () => request.get('/inventory/categories'), + createCategory: (data) => request.post('/inventory/categories', data), + + // 物料 + getMaterials: (params) => request.get('/inventory/materials', { params }), + createMaterial: (data) => request.post('/inventory/materials', data), + updateMaterial: (id, data) => request.put(`/inventory/materials/${id}`, data), + deleteMaterial: (id) => request.delete(`/inventory/materials/${id}`), + + // 出入库 + purchaseIn: (data) => request.post('/inventory/transactions/purchase', data), + allocateOut: (data) => request.post('/inventory/transactions/allocate', data), + returnIn: (data) => request.post('/inventory/transactions/return', data), + getTransactions: (params) => request.get('/inventory/transactions', { params }), + getTransactionDetail: (id) => request.get(`/inventory/transactions/${id}`), + + // 批次 + getBatches: (material_id) => request.get('/inventory/batches', { params: { material_id } }), + + // 序列号设备 + getSerialDevices: (params) => request.get('/inventory/serial-devices', { params }), + + // 统计 + getSummary: () => request.get('/inventory/summary'), + + // 盘点 + getChecks: (params) => request.get('/inventory/checks', { params }), + createCheck: (data) => request.post('/inventory/checks', data), + adjustCheck: (id) => request.post(`/inventory/checks/${id}/adjust`), +} diff --git a/frontend/src/api/role.js b/frontend/src/api/role.js new file mode 100644 index 0000000..a866f7f --- /dev/null +++ b/frontend/src/api/role.js @@ -0,0 +1,6 @@ +import request from '../utils/request' + +export const getRoles = () => request.get('/roles') +export const getPermissions = () => request.get('/permissions') +export const updateRolePermissions = (role, permissions) => + request.put(`/roles/${role}/permissions`, { permissions }) diff --git a/frontend/src/api/settings.js b/frontend/src/api/settings.js new file mode 100644 index 0000000..c7488dc --- /dev/null +++ b/frontend/src/api/settings.js @@ -0,0 +1,4 @@ +import request from '../utils/request' + +export const getSettings = () => request.get('/settings') +export const updateCheckInterval = (seconds) => request.put(`/settings/check_interval?seconds=${seconds}`) diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js new file mode 100644 index 0000000..6b33a76 --- /dev/null +++ b/frontend/src/api/user.js @@ -0,0 +1,5 @@ +import request from '../utils/request' + +export const getUsers = (params) => request.get('/users', { params }) +export const updateUserRole = (id, data) => request.put(`/users/${id}/role`, data) +export const toggleUser = (id) => request.put(`/users/${id}/toggle`) diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 4670df8..ef23052 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -1,7 +1,44 @@ @@ -100,23 +194,32 @@ import { computed, ref, onMounted, onUnmounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useAuthStore } from '../stores/auth' import { useThemeStore } from '../stores/theme' +import { useMobile } from '../composables/useMobile' const router = useRouter() const route = useRoute() const authStore = useAuthStore() const themeStore = useThemeStore() +const { isMobile } = useMobile() const currentTime = ref('') +const moreMenuOpen = ref(false) const updateTime = () => { const now = new Date() currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false }) } let timer = null -onMounted(() => { updateTime(); timer = setInterval(updateTime, 1000) }) +onMounted(async () => { + updateTime() + timer = setInterval(updateTime, 1000) + if (authStore.token && !authStore.user) { + await authStore.fetchProfile() + } +}) onUnmounted(() => clearInterval(timer)) -const navItems = [ +const allNavItems = [ { path: '/dashboard', label: '统计概览', @@ -135,7 +238,7 @@ const navItems = [ }, { path: '/olt', - label: 'OLT 管理', + label: 'OLT', icon: ` @@ -152,15 +255,69 @@ const navItems = [ ` }, { - path: '/import', - label: '数据导入', + path: '/inventory', + label: '库存管理', icon: ` - - + + + ` + }, + { + path: '/users', + label: '用户管理', + adminOnly: true, + icon: ` + + + + ` + }, + { + path: '/roles', + label: '角色权限', + adminOnly: true, + icon: ` + + + ` + }, + { + path: '/settings', + label: '系统设置', + adminOnly: true, + icon: ` + + + ` + }, + { + path: '/audit', + label: '审计日志', + adminOnly: true, + icon: ` + + + + ` }, ] +const navItems = computed(() => + allNavItems.filter(item => !item.adminOnly || authStore.isAdmin) +) + +// 移动端底部导航只显示核心3项 +const mobileNavItems = computed(() => navItems.value.slice(0, 3)) + +// 更多菜单中的额外项(非前3项) +const extraNavItems = computed(() => + navItems.value.slice(3).map((item, i) => ({ + ...item, + desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '查看操作审计记录'][i] || '' + })) +) + const isActive = (path) => route.path === path || route.path.startsWith(path + '/') const pageNameMap = { @@ -168,7 +325,11 @@ const pageNameMap = { '/devices': '设备列表', '/olt': 'OLT 管理', '/charts': '统计图表', - '/import': '数据导入', + '/inventory': '库存管理', + '/users': '用户管理', + '/roles': '角色权限', + '/settings': '系统设置', + '/audit': '审计日志', } const currentPageName = computed(() => pageNameMap[route.path] || '页面') @@ -182,11 +343,12 @@ const handleLogout = () => { .app-shell { display: flex; height: 100vh; + height: 100dvh; overflow: hidden; background: var(--bg-base); } -/* ── 侧边栏 ── */ +/* ── 侧边栏(桌面端) ── */ .sidebar { width: 220px; flex-shrink: 0; @@ -199,7 +361,6 @@ const handleLogout = () => { z-index: 10; } -/* 品牌 */ .sidebar-brand { display: flex; align-items: center; @@ -241,7 +402,6 @@ const handleLogout = () => { letter-spacing: 0.03em; } -/* 状态指示 */ .sidebar-status { display: flex; align-items: center; @@ -271,7 +431,6 @@ const handleLogout = () => { letter-spacing: 0.03em; } -/* 导航 */ .sidebar-nav { flex: 1; padding: 12px 10px; @@ -315,7 +474,6 @@ const handleLogout = () => { } .nav-item.active .nav-icon { opacity: 1; } - .nav-label { flex: 1; } .nav-indicator { @@ -329,7 +487,6 @@ const handleLogout = () => { .nav-item.active .nav-indicator { opacity: 1; } -/* 底部 */ .sidebar-footer { padding: 14px 10px; border-top: 1px solid var(--border-subtle); @@ -366,7 +523,19 @@ const handleLogout = () => { min-width: 0; } -/* 顶部栏 */ +.main-area--mobile { + padding-top: 52px; + padding-bottom: 64px; +} + +@media (display-mode: standalone) { + .main-area--mobile { + padding-top: calc(52px + env(safe-area-inset-top)); + padding-bottom: calc(64px + env(safe-area-inset-bottom)); + } +} + +/* 桌面端顶部栏 */ .topbar { height: 52px; flex-shrink: 0; @@ -391,19 +560,9 @@ const handleLogout = () => { font-size: 13px; } -.breadcrumb-root { - color: var(--text-muted); -} - -.breadcrumb-sep { - color: var(--border-strong); - font-size: 12px; -} - -.breadcrumb-current { - color: var(--text-secondary); - font-weight: 500; -} +.breadcrumb-root { color: var(--text-muted); } +.breadcrumb-sep { color: var(--border-strong); font-size: 12px; } +.breadcrumb-current { color: var(--text-secondary); font-weight: 500; } .topbar-time { font-family: var(--font-mono); @@ -425,14 +584,17 @@ const handleLogout = () => { color: var(--text-muted); } -/* 页面内容 */ .page-content { flex: 1; overflow-y: auto; overflow-x: hidden; } -/* 主题切换按钮 */ +.page-content--mobile { + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + .theme-toggle { display: flex; align-items: center; @@ -455,7 +617,330 @@ const handleLogout = () => { background: var(--accent-dim); } -.theme-label { +.theme-label { letter-spacing: 0.02em; } + +/* ── 移动端顶栏 ── */ +.mobile-topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 52px; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +/* standalone 模式下顶栏需要为刘海让出空间 */ +@media (display-mode: standalone) { + .mobile-topbar { + height: calc(52px + env(safe-area-inset-top)); + align-items: flex-end; + padding-bottom: 8px; + padding-top: env(safe-area-inset-top); + } +} + +.mobile-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.mobile-brand .brand-icon { + width: 30px; + height: 30px; + background: var(--accent-dim); + border: 1px solid var(--border-accent); + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.mobile-brand-title { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: 0.05em; +} + +.mobile-topbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.mobile-time { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); + letter-spacing: 0.04em; +} + +.mobile-theme-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; +} + +.mobile-theme-btn:active { + background: var(--accent-dim); + border-color: var(--border-accent); + color: var(--accent); +} + +/* ── 移动端底部导航 ── */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 64px; + z-index: 100; + display: flex; + align-items: stretch; + background: var(--bg-surface); + border-top: 1px solid var(--border-subtle); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +@media (display-mode: standalone) { + .bottom-nav { + height: calc(64px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + } +} + +.bottom-nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + text-decoration: none; + color: var(--text-muted); + background: transparent; + border: none; + cursor: pointer; + font-family: var(--font-sans); + transition: color 0.18s; + padding: 8px 4px; + min-height: 44px; + position: relative; +} + +.bottom-nav-item::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 2px; + background: var(--accent); + border-radius: 0 0 2px 2px; + transition: width 0.2s ease; +} + +.bottom-nav-item.active { + color: var(--accent); +} + +.bottom-nav-item.active::before { + width: 32px; +} + +.bottom-nav-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.bottom-nav-label { + font-size: 10px; + font-weight: 500; letter-spacing: 0.02em; + white-space: nowrap; +} + +/* ── 更多菜单抽屉 ── */ +.more-drawer { + position: fixed; + inset: 0; + z-index: 200; +} + +.more-drawer-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +.more-drawer-panel { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--bg-surface); + border-radius: 20px 20px 0 0; + border-top: 1px solid var(--border-default); + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.4); +} + +.more-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 20px 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.more-drawer-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.more-drawer-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; +} + +.more-drawer-items { + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.more-drawer-item { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 12px; + border-radius: var(--radius-md); + text-decoration: none; + color: var(--text-secondary); + background: transparent; + border: none; + cursor: pointer; + font-family: var(--font-sans); + width: 100%; + text-align: left; + transition: background 0.15s; + min-height: 56px; +} + +.more-drawer-item:active, +.more-drawer-item.active { + background: var(--accent-dim); + color: var(--accent); +} + +.more-item-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + color: var(--text-secondary); + flex-shrink: 0; +} + +.more-drawer-item.active .more-item-icon { + background: var(--accent-dim); + border-color: var(--border-accent); + color: var(--accent); +} + +.more-item-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.more-item-label { + font-size: 14px; + font-weight: 500; + color: inherit; +} + +.more-item-desc { + font-size: 11px; + color: var(--text-muted); +} + +.more-item-arrow { + color: var(--text-muted); + flex-shrink: 0; +} + +.more-drawer-logout { + margin-top: 4px; + border-top: 1px solid var(--border-subtle); + padding-top: 16px; + color: var(--danger); +} + +.more-drawer-logout .more-item-icon { + color: var(--danger); + background: var(--danger-dim); + border-color: rgba(239,68,68,0.25); +} + +/* 抽屉动画 */ +.slide-up-enter-active, +.slide-up-leave-active { + transition: opacity 0.25s ease; +} + +.slide-up-enter-active .more-drawer-panel, +.slide-up-leave-active .more-drawer-panel { + transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.slide-up-enter-from, +.slide-up-leave-to { + opacity: 0; +} + +.slide-up-enter-from .more-drawer-panel, +.slide-up-leave-to .more-drawer-panel { + transform: translateY(100%); } diff --git a/frontend/src/composables/useMobile.js b/frontend/src/composables/useMobile.js new file mode 100644 index 0000000..d1f5819 --- /dev/null +++ b/frontend/src/composables/useMobile.js @@ -0,0 +1,16 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +const MOBILE_BREAKPOINT = 768 + +export function useMobile() { + const isMobile = ref(window.innerWidth < MOBILE_BREAKPOINT) + + const onResize = () => { + isMobile.value = window.innerWidth < MOBILE_BREAKPOINT + } + + onMounted(() => window.addEventListener('resize', onResize)) + onUnmounted(() => window.removeEventListener('resize', onResize)) + + return { isMobile } +} diff --git a/frontend/src/composables/usePermission.js b/frontend/src/composables/usePermission.js new file mode 100644 index 0000000..b79a876 --- /dev/null +++ b/frontend/src/composables/usePermission.js @@ -0,0 +1,8 @@ +import { useAuthStore } from '../stores/auth' + +export function usePermission() { + const authStore = useAuthStore() + return { + can: (perm) => authStore.hasPermission(perm), + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 5445566..d795ce5 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,11 +1,47 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' -import ElementPlus from 'element-plus' +import ElementPlus, { ElMessage } from 'element-plus' import 'element-plus/dist/index.css' import './styles/theme.css' import App from './App.vue' import router from './router' +// 检测 standalone 模式,将 safe-area-inset-top 写入 CSS 变量供弹窗/消息使用 +function getSafeAreaTop() { + const div = document.createElement('div') + div.style.cssText = 'position:fixed;top:env(safe-area-inset-top);left:0;width:0;height:0;visibility:hidden;' + document.body.appendChild(div) + const top = parseInt(getComputedStyle(div).top) || 0 + document.body.removeChild(div) + return top +} + +const isStandalone = window.navigator.standalone === true || + window.matchMedia('(display-mode: standalone)').matches + +if (isStandalone) { + const sat = getSafeAreaTop() + document.documentElement.style.setProperty('--sat', sat + 'px') + + // 全局覆盖 ElMessage,自动加上顶栏 + 刘海的偏移 + const MESSAGE_OFFSET = 52 + sat + 8 // 顶栏高度 + 刘海 + 间距 + const _origMessage = ElMessage + const wrap = (opts) => { + if (typeof opts === 'string') return _origMessage({ message: opts, offset: MESSAGE_OFFSET }) + return _origMessage({ offset: MESSAGE_OFFSET, ...opts }) + } + ;['success', 'warning', 'info', 'error'].forEach(type => { + wrap[type] = (opts) => { + if (typeof opts === 'string') return _origMessage[type]({ message: opts, offset: MESSAGE_OFFSET }) + return _origMessage[type]({ offset: MESSAGE_OFFSET, ...opts }) + } + }) + wrap.closeAll = _origMessage.closeAll + // 替换全局 ElMessage(各页面通过 import { ElMessage } 使用的不受影响, + // 但通过 app.config.globalProperties.$message 调用的会生效) + window.__elMessageOffset = MESSAGE_OFFSET +} + const app = createApp(App) app.use(createPinia()) app.use(router) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index fffe5c8..7e68ba5 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -29,11 +29,6 @@ const routes = [ name: 'DeviceList', component: () => import('../views/DeviceList.vue') }, - { - path: '/import', - name: 'ImportData', - component: () => import('../views/ImportData.vue') - }, { path: '/charts', name: 'Charts', @@ -43,7 +38,36 @@ const routes = [ path: '/olt', name: 'OltManage', component: () => import('../views/OltManage.vue') - } + }, + { + path: '/inventory', + name: 'Inventory', + component: () => import('../views/Inventory.vue') + }, + { + path: '/users', + name: 'UserManage', + component: () => import('../views/UserManage.vue'), + meta: { requiresRole: 'admin' } + }, + { + path: '/roles', + name: 'RolePermissions', + component: () => import('../views/RolePermissions.vue'), + meta: { requiresRole: 'admin' } + }, + { + path: '/settings', + name: 'Settings', + component: () => import('../views/Settings.vue'), + meta: { requiresRole: 'admin' } + }, + { + path: '/audit', + name: 'AuditLog', + component: () => import('../views/AuditLog.vue'), + meta: { requiresRole: 'admin' } + }, ] } ] @@ -53,15 +77,33 @@ const router = createRouter({ routes }) -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { const authStore = useAuthStore() + if (to.meta.requiresAuth && !authStore.token) { - next('/login') - } else if (to.path === '/login' && authStore.token) { - next('/dashboard') - } else { - next() + return next('/login') } + if (to.path === '/login' && authStore.token) { + return next('/dashboard') + } + + // 需要角色检查时,确保 user 信息已加载 + if (to.meta.requiresRole && authStore.token) { + if (!authStore.user) { + await authStore.fetchProfile() + } + if (authStore.role !== to.meta.requiresRole) { + return next('/dashboard') + } + } + + // 加载权限列表(仅首次) + if (authStore.token && authStore.permissions.length === 0) { + await authStore.fetchPermissions() + } + + next() }) export default router + diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index effaca7..e308025 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,21 +1,69 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, computed } from 'vue' import * as authApi from '../api/auth' +import axios from 'axios' + +function decodeJwtPayload(token) { + try { + const b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/') + return JSON.parse(atob(b64)) + } catch { + return {} + } +} export const useAuthStore = defineStore('auth', () => { const token = ref(localStorage.getItem('token') || '') const user = ref(null) + const permissions = ref([]) + + // 直接从 JWT payload 读 role,无需额外请求 + const role = computed(() => { + if (user.value?.role) return user.value.role + if (token.value) return decodeJwtPayload(token.value).role || 'user' + return 'user' + }) + const isAdmin = computed(() => role.value === 'admin') + + const hasPermission = (perm) => { + if (isAdmin.value) return true + return permissions.value.includes(perm) + } const setToken = (newToken) => { token.value = newToken localStorage.setItem('token', newToken) } + const fetchProfile = async () => { + if (!token.value) return + try { + const res = await authApi.getProfile() + user.value = res.data + } catch { + // token 失效时静默处理 + } + } + + const fetchPermissions = async () => { + if (!token.value) return + try { + const res = await axios.get('/api/auth/permissions', { + headers: { Authorization: `Bearer ${token.value}` } + }) + permissions.value = res.data.permissions || [] + } catch { + permissions.value = [] + } + } + const logout = () => { token.value = '' user.value = null + permissions.value = [] localStorage.removeItem('token') } - return { token, user, setToken, logout } + return { token, user, role, isAdmin, permissions, hasPermission, setToken, fetchProfile, fetchPermissions, logout } }) + diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index b9b6a15..3752e2a 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -68,6 +68,9 @@ html, body, #app { font-family: var(--font-sans); font-size: 14px; -webkit-font-smoothing: antialiased; + /* iOS standalone 模式下禁止长按弹出菜单、文字选中 */ + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; } /* ── Element Plus 深色覆盖 ── */ @@ -730,3 +733,92 @@ html { transition-duration: 0.2s; transition-timing-function: ease; } + +/* ============================================================ + 移动端全局适配 + ============================================================ */ + +/* 防止 iOS 双击缩放 */ +html { + touch-action: manipulation; +} + +/* 移动端 Element Plus 对话框全屏适配 */ +@media (max-width: 767px) { + /* overlay 容器整体下移,避开固定顶栏(52px)并留出安全间距 */ + .el-overlay-dialog { + padding-top: 60px !important; + padding-bottom: 72px !important; /* 底部导航 64px + 8px 间距 */ + box-sizing: border-box !important; + } + + .el-dialog { + width: calc(100vw - 24px) !important; + max-width: 100% !important; + margin: 0 auto !important; + border-radius: var(--radius-lg) !important; + /* 最大高度 = 视口 - 顶栏 - 底导航 - 上下间距 */ + max-height: calc(100vh - 60px - 72px) !important; + display: flex !important; + flex-direction: column !important; + } + + .el-dialog__body { + padding: 16px !important; + flex: 1 !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; + /* 不再写死 max-height,由父容器控制 */ + max-height: none !important; + } + + .el-dialog__footer { + padding: 12px 16px !important; + flex-shrink: 0 !important; + } + + /* 表格在移动端允许横向滚动 */ + .el-table { + overflow-x: auto !important; + } + + /* 分页在移动端居中 */ + .el-pagination { + justify-content: center !important; + flex-wrap: wrap !important; + } + + /* 按钮最小触摸区域 */ + .el-button { + min-height: 36px !important; + } + + /* 输入框高度 */ + .el-input__wrapper { + min-height: 36px !important; + } + + /* 消息提示位置 */ + .el-message { + min-width: 240px !important; + max-width: calc(100vw - 32px) !important; + } +} + +/* ── iOS 主屏幕 standalone 模式:弹窗和消息额外下移刘海高度 ── */ +/* --sat 由 main.js 在 standalone 模式下动态写入,Safari 中不存在默认为 0 */ +@media (display-mode: standalone) and (max-width: 767px) { + .el-overlay-dialog { + padding-top: calc(60px + var(--sat, 0px)) !important; + padding-bottom: calc(72px + env(safe-area-inset-bottom)) !important; + } + + .el-dialog { + max-height: calc(100vh - 60px - 72px - var(--sat, 0px) - env(safe-area-inset-bottom)) !important; + } + + /* 消息提示下移 */ + .el-message-list { + top: calc(20px + var(--sat, 0px)) !important; + } +} diff --git a/frontend/src/utils/message.js b/frontend/src/utils/message.js new file mode 100644 index 0000000..cf3cc47 --- /dev/null +++ b/frontend/src/utils/message.js @@ -0,0 +1,31 @@ +/** + * 封装 ElMessage,在 iOS standalone 模式下自动加上顶栏+刘海偏移 + */ +import { ElMessage as _ElMessage } from 'element-plus' + +const isStandalone = window.navigator.standalone === true || + window.matchMedia('(display-mode: standalone)').matches + +function getOffset() { + if (!isStandalone) return 20 + const sat = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sat')) || 0 + return 52 + sat + 8 +} + +function message(opts) { + const offset = getOffset() + if (typeof opts === 'string') return _ElMessage({ message: opts, offset }) + return _ElMessage({ offset, ...opts }) +} + +;['success', 'warning', 'info', 'error'].forEach(type => { + message[type] = (opts) => { + const offset = getOffset() + if (typeof opts === 'string') return _ElMessage[type]({ message: opts, offset }) + return _ElMessage[type]({ offset, ...opts }) + } +}) + +message.closeAll = _ElMessage.closeAll + +export { message as ElMessage } diff --git a/frontend/src/views/AuditLog.vue b/frontend/src/views/AuditLog.vue new file mode 100644 index 0000000..6e14e69 --- /dev/null +++ b/frontend/src/views/AuditLog.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 9ae1764..5b92e2f 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -11,7 +11,9 @@ - {{ loading ? '刷新中…' : '刷新数据' }} + 刷新中… + {{ lastUpdated }} + 刷新数据 @@ -198,10 +200,14 @@ import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import request from '../utils/request' +import { useMobile } from '../composables/useMobile' + +const { isMobile } = useMobile() const router = useRouter() const loading = ref(false) const data = ref({}) +const lastUpdated = ref('') const townVisible = ref(false) const selectedTown = ref(null) @@ -238,6 +244,7 @@ const loadData = async () => { try { const { data: d } = await request.get('/stats/dashboard') data.value = d + lastUpdated.value = '更新于 ' + new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) } finally { loading.value = false } @@ -589,4 +596,66 @@ onMounted(loadData) font-size: 12px; color: var(--text-muted); } + +/* ── 移动端响应式 ── */ +@media (max-width: 767px) { + .dashboard { + padding: 16px 12px; + } + + .page-header { + margin-bottom: 16px; + } + + .page-title { + font-size: 18px; + } + + .refresh-btn { + padding: 8px 12px; + font-size: 12px; + } + + /* 统计卡片:2列 */ + .stat-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-bottom: 16px; + } + + .stat-card { + padding: 14px; + } + + .stat-rate { + font-size: 28px; + margin-bottom: 8px; + } + + .stat-meta { + margin-bottom: 10px; + } + + /* 学校列表:单列堆叠 */ + .list-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .school-list { + max-height: 260px; + } + + .school-row { + grid-template-columns: 1fr 60px 52px; + gap: 8px; + padding: 10px 8px; + min-height: 44px; + } + + .school-name { + font-size: 13px; + } +} + diff --git a/frontend/src/views/DeviceList.vue b/frontend/src/views/DeviceList.vue index 90dddc0..f92e54c 100644 --- a/frontend/src/views/DeviceList.vue +++ b/frontend/src/views/DeviceList.vue @@ -7,12 +7,15 @@ ONU 终端设备管理
- + 清空状态 - + {{ refreshing ? '更新中…' : '全部更新' }} + + 数据导入 +
@@ -85,8 +88,9 @@ - + @@ -130,9 +134,114 @@ + +
+
+ +
+
+ {{ formatMac(device.mac_address) }} + {{ device.school_name }} +
+
+ + + {{ device.status === 'online' ? '在线' : device.status === 'offline' ? '离线' : '未知' }} + + + + +
+
+ + +
+
+ 楼宇 + {{ device.building || '—' }} +
+
+ 场所 + {{ device.place_type || '—' }} +
+
+ 房间号 + {{ device.room_number || '—' }} +
+
+ + +
+
+ + + + +
+
+
+ 距离 + {{ formatDistance(device.distance_m) }} +
+
+ 端口 + {{ formatPort(device) }} +
+
+ 型号 + {{ device.model || '—' }} +
+
+ 所属 OLT + {{ device.olt_location || '—' }} +
+
+ 备注 + {{ device.notes }} +
+
+
+
+
暂无设备数据
+
+
+
+ + 第 {{ page }} 页 / 共 {{ Math.ceil(total / pageSize) }} 页 + +
@@ -220,7 +334,7 @@ - {{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '—' }} + {{ formatDistance(selectedDevice.distance_m) }} {{ formatPort(selectedDevice) }} @@ -230,15 +344,44 @@ {{ selectedDevice.notes || '—' }} + + +
+ 当前 MAC:{{ formatMac(selectedDevice.mac_address) }} +
+ + + + + + + + +
+
更换历史
+
+ {{ r.old_mac }} + + {{ r.new_mac }} + {{ fmtReplaceTime(r.replaced_at) }} +
+
+ +
+ @@ -301,14 +444,65 @@ save force + + +
+ 请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。 +
+
+ 下载导入模板 +
+ +
+ + + + +
拖拽文件到此处,或 点击选择
+
支持 .xlsx / .xls 格式
+
+
+
+ +
+
+ 第 {{ f.row }} 行:{{ f.reason }} +
+
+
+ +
+ + + diff --git a/frontend/src/views/OltManage.vue b/frontend/src/views/OltManage.vue index 83534b5..05d88ed 100644 --- a/frontend/src/views/OltManage.vue +++ b/frontend/src/views/OltManage.vue @@ -7,13 +7,14 @@ 光线路终端设备配置与监控
- + 添加 OLT 批量导入 - 下载模板 - + 下载模板 + 重复 MAC - + 新增设备 - + 环路检测 - + 快速扫描
@@ -42,7 +43,14 @@
- + +
+ + + +
+ + + + + + + +
+
+
+
+ {{ device.ip_address }} + {{ device.location || '—' }} +
+
+ + + + + + 环路 {{ loopStatusMap[device.id].length }} + + + 正常 + +
+
+
+ + +
+
+
暂无 OLT 设备
+
@@ -206,6 +263,11 @@ + + + + + @@ -225,6 +287,7 @@ {{ selectedDevice.username }} + {{ selectedDevice.region || '—' }} {{ selectedDevice.location || '—' }} {{ selectedDevice.slot_command || '—' }} @@ -236,7 +299,7 @@
- + @@ -250,7 +313,7 @@ 扫描发现 - + @@ -260,8 +323,8 @@
- 编辑 - 删除 + 编辑 + 删除
关闭
@@ -298,7 +361,8 @@
{{ portStatusText(port) }}
-
点击端口可切换开启/关闭状态
+
点击端口可切换开启/关闭状态
+
仅查看端口状态(无管理权限)
+ + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..e81782a --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/frontend/src/views/UserManage.vue b/frontend/src/views/UserManage.vue new file mode 100644 index 0000000..ece9c14 --- /dev/null +++ b/frontend/src/views/UserManage.vue @@ -0,0 +1,454 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 54b12d3..ab19e41 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -8,7 +8,7 @@ export default defineConfig({ port: 5173, proxy: { '/api': { - target: 'http://localhost:8000', + target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001', changeOrigin: true } } diff --git a/审计日志系统设计方案.md b/审计日志系统设计方案.md new file mode 100644 index 0000000..7c7cc9d --- /dev/null +++ b/审计日志系统设计方案.md @@ -0,0 +1,535 @@ +# H3C ONU设备管理系统 - 审计日志系统设计方案 + +## 项目概述 + +本方案为 H3C ONU 设备管理系统设计一套完整的审计日志系统,用于记录所有用户操作(手动和自动触发),支持误操作恢复和问责追溯。 + +## 设计目标 + +1. **全面记录**:记录所有用户操作,包括认证、设备管理、OLT端口操作等 +2. **详细审计**:记录操作详情(用户、时间、IP、参数、结果等) +3. **快速查询**:提供超级管理员查询界面 +4. **长期存储**:日志保留90天,支持自动清理 +5. **性能友好**:异步执行,不影响主业务流程 +6. **Docker友好**:支持容器化部署,日志映射到宿主机 + +## 系统架构 + +### 整体架构图 + +``` +用户操作 → API请求 → 审计日志中间件 → 异步任务队列 → + ↓ +[数据库] 记录核心信息 + [文件系统] 记录详细日志 + ↓ +查询界面 ← 日志服务 ← 定期清理任务 +``` + +### 技术栈 +- **后端框架**:FastAPI + SQLAlchemy + Celery +- **数据库**:PostgreSQL(核心信息)+ 文件系统(详细日志) +- **消息队列**:Redis(Celery broker) +- **存储**:Docker卷映射到宿主机 + +## 详细设计 + +### 1. 数据库设计 + +#### 1.1 审计日志表 (`audit_logs`) + +```sql +CREATE TABLE audit_logs ( + id SERIAL PRIMARY KEY, + -- 用户信息 + user_id VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL, + user_role VARCHAR(50), + + -- 操作信息 + action_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + action_type VARCHAR(50) NOT NULL, -- 操作类型 + action_subtype VARCHAR(50), -- 操作子类型 + + -- 请求信息 + ip_address VARCHAR(45), + user_agent TEXT, + request_method VARCHAR(10), + request_path VARCHAR(500), + + -- 操作结果 + status VARCHAR(20) NOT NULL, -- success, failed, error + status_code INTEGER, + + -- 资源信息 + resource_type VARCHAR(50), -- device, olt, port, user, etc. + resource_id VARCHAR(100), + resource_name VARCHAR(200), + + -- 简要信息 + description TEXT NOT NULL, + + -- 详细日志 + request_params JSONB, + response_data JSONB, + error_message TEXT, + + -- 文件存储 + details_file_path VARCHAR(500), + + -- 元数据 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 索引设计 +CREATE INDEX idx_audit_logs_action_time ON audit_logs(action_time); +CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_action_type ON audit_logs(action_type); +CREATE INDEX idx_audit_logs_resource_type ON audit_logs(resource_type); +CREATE INDEX idx_audit_logs_status ON audit_logs(status); +``` + +#### 1.2 操作类型分类 + +| 操作类型 | 操作子类型 | 描述 | 示例 | +|---------|-----------|------|------| +| `auth` | `login`, `logout`, `token_refresh` | 认证相关操作 | 用户登录、登出 | +| `device` | `create`, `update`, `delete`, `status_check`, `import` | 设备管理操作 | 创建设备、更新设备信息 | +| `olt` | `create`, `update`, `delete`, `port_enable`, `port_disable` | OLT管理操作 | 启用/关闭OLT端口 | +| `user` | `create`, `update`, `delete`, `role_change` | 用户管理操作 | 创建用户、修改角色 | +| `system` | `config_update`, `task_trigger`, `cleanup` | 系统操作 | 触发状态检查、清理任务 | +| `inventory` | `stock_in`, `stock_out`, `transfer`, `count` | 库存管理操作 | 入库、出库、盘点 | + +### 2. 文件存储设计 + +#### 2.1 目录结构 + +``` +logs/ +├── audit/ # 审计日志目录 +│ ├── 2025-04-05/ # 按日期分目录 +│ │ ├── auth/ # 按操作类型分子目录 +│ │ │ ├── login_20250405193500_user123_abc123.json +│ │ │ └── logout_20250405194000_user456_def456.json +│ │ ├── device/ +│ │ ├── olt/ +│ │ └── ... +│ ├── 2025-04-06/ +│ └── ... +├── app/ # 应用日志目录 +│ └── app.log +└── celery/ # Celery任务日志目录 + └── celery.log +``` + +#### 2.2 详细日志文件格式(JSON) + +```json +{ + "log_id": "abc123def456", + "audit_log_id": 12345, + "timestamp": "2025-04-05T19:35:00+08:00", + "user": { + "id": "user123", + "username": "张三", + "role": "admin", + "ip_address": "192.168.1.100" + }, + "action": { + "type": "olt_port_enable", + "subtype": "port_operation", + "description": "启用OLT端口", + "resource": { + "type": "olt_port", + "id": "olt-1-port-2", + "name": "OLT-1端口2" + } + }, + "request": { + "method": "POST", + "path": "/api/olt/1/port/2/enable", + "headers": { + "user-agent": "Mozilla/5.0...", + "content-type": "application/json" + }, + "body": { + "reason": "设备上线", + "operator": "张三" + }, + "query_params": {} + }, + "response": { + "status_code": 200, + "body": { + "success": true, + "message": "端口启用成功", + "data": { + "port_id": "olt-1-port-2", + "status": "enabled", + "enabled_at": "2025-04-05T19:35:00+08:00" + } + } + }, + "execution": { + "duration_ms": 1250, + "start_time": "2025-04-05T19:34:58.750+08:00", + "end_time": "2025-04-05T19:35:00.000+08:00" + }, + "system": { + "service": "backend", + "version": "v0.7.0", + "environment": "production" + } +} +``` + +### 3. 组件设计 + +#### 3.1 审计日志中间件 (`audit_middleware.py`) + +**功能**: +- 拦截所有API请求(排除健康检查、文档等) +- 提取请求和响应信息 +- 异步发送日志到任务队列 + +**关键特性**: +- 支持请求体读取(JSON格式) +- 支持响应体捕获 +- 异常处理,不影响主流程 +- 性能监控(记录执行时间) + +#### 3.2 审计日志服务 (`audit_service.py`) + +**功能**: +- 解析日志数据,确定操作类型 +- 创建数据库记录 +- 保存详细日志到文件系统 +- 提供查询接口 + +**关键方法**: +- `create_audit_log()`: 创建日志记录 +- `query_logs()`: 查询日志(支持多条件筛选) +- `cleanup_old_logs()`: 清理90天前的日志 +- `export_logs()`: 导出日志 + +#### 3.3 Celery任务 (`audit_tasks.py`) + +**功能**: +- 异步处理日志记录 +- 定期清理任务 +- 日志归档任务 + +**任务列表**: +- `create_audit_log_task`: 创建审计日志 +- `cleanup_audit_logs_task`: 清理旧日志(每天执行) +- `archive_audit_logs_task`: 归档日志(每月执行) + +#### 3.4 API接口 (`audit_api.py`) + +**端点设计**: + +| 端点 | 方法 | 描述 | 权限 | +|------|------|------|------| +| `/api/audit/logs` | GET | 查询审计日志 | 超级管理员 | +| `/api/audit/logs/{id}` | GET | 获取单条日志详情 | 超级管理员 | +| `/api/audit/logs/export` | POST | 导出日志 | 超级管理员 | +| `/api/audit/stats` | GET | 获取日志统计 | 超级管理员 | + +**查询参数**: +- `start_time`: 开始时间 +- `end_time`: 结束时间 +- `user_id`: 用户ID +- `action_type`: 操作类型 +- `resource_type`: 资源类型 +- `status`: 状态(success/failed/error) +- `page`: 页码 +- `page_size`: 每页数量 + +### 4. 前端界面设计 + +#### 4.1 日志查询页面 + +**功能**: +- 时间范围选择器 +- 多条件筛选(用户、操作类型、状态等) +- 分页显示 +- 导出功能 + +**界面元素**: +- 查询条件表单 +- 日志列表表格 +- 分页控件 +- 导出按钮 + +#### 4.2 日志详情页面 + +**功能**: +- 显示日志详细信息 +- 查看详细日志文件内容 +- 操作回放(显示请求和响应) + +## 实施步骤 + +### 阶段一:基础框架搭建(1-2天) + +1. **创建数据库模型** + - 创建 `audit_logs` 表 + - 添加数据库迁移 + +2. **实现基础服务** + - 创建 `AuditService` 类 + - 实现日志创建和存储逻辑 + +3. **配置Docker** + - 更新 `docker-compose.yml` 日志映射 + - 确保目录权限正确 + +### 阶段二:中间件和异步处理(2-3天) + +1. **实现审计中间件** + - 创建 `AuditMiddleware` + - 集成到FastAPI应用 + +2. **实现Celery任务** + - 创建审计日志任务 + - 配置任务队列 + +3. **测试异步流程** + - 验证日志记录不阻塞主流程 + - 测试异常处理 + +### 阶段三:查询接口和界面(2-3天) + +1. **实现API接口** + - 创建审计日志查询端点 + - 实现多条件筛选 + +2. **开发前端界面** + - 创建日志查询页面 + - 实现筛选和分页功能 + +3. **实现导出功能** + - 支持JSON/CSV格式导出 + - 批量导出功能 + +### 阶段四:清理和优化(1-2天) + +1. **实现自动清理** + - 创建清理任务 + - 测试清理逻辑 + +2. **性能优化** + - 数据库查询优化 + - 文件IO优化 + +3. **监控和告警** + - 添加日志记录监控 + - 设置磁盘空间告警 + +## 配置要求 + +### 环境变量 + +```bash +# 审计日志配置 +AUDIT_LOG_ENABLED=true +AUDIT_LOG_RETENTION_DAYS=90 +AUDIT_LOG_DIR=/app/logs/audit +AUDIT_LOG_LEVEL=INFO + +# Celery配置 +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 +``` + +### Docker配置更新 + +```yaml +services: + backend: + build: ./backend + volumes: + - ./backend/logs:/app/logs # 应用日志 + - ./logs/audit:/app/logs/audit # 审计日志专用目录 + environment: + - AUDIT_LOG_ENABLED=true + - AUDIT_LOG_DIR=/app/logs/audit +``` + +## 测试方案 + +### 单元测试 + +1. **服务层测试** + - 测试 `AuditService.create_audit_log()` + - 测试 `AuditService.query_logs()` + - 测试 `AuditService.cleanup_old_logs()` + +2. **中间件测试** + - 测试请求拦截 + - 测试异常处理 + - 测试性能影响 + +### 集成测试 + +1. **端到端测试** + - 模拟用户操作,验证日志记录 + - 测试查询接口 + - 测试导出功能 + +2. **性能测试** + - 高并发下的日志记录性能 + - 大数据量下的查询性能 + +### 验收测试 + +1. **功能验收** + - 验证所有操作类型都被记录 + - 验证查询功能正常工作 + - 验证清理功能按预期工作 + +2. **非功能验收** + - 性能:日志记录不影响API响应时间(<50ms) + - 可靠性:日志不丢失,可追溯 + - 安全性:只有超级管理员可访问 + +## 维护和监控 + +### 日常维护 + +1. **磁盘空间监控** + - 监控日志目录大小 + - 设置磁盘使用率告警(>80%) + +2. **性能监控** + - 监控日志记录延迟 + - 监控数据库查询性能 + +3. **定期检查** + - 每周检查清理任务执行情况 + - 每月检查日志完整性 + +### 故障处理 + +1. **日志记录失败** + - 检查Celery worker状态 + - 检查磁盘空间 + - 检查文件权限 + +2. **查询性能下降** + - 优化数据库索引 + - 增加查询缓存 + - 考虑分表策略 + +## 扩展性考虑 + +### 未来扩展 + +1. **实时告警** + - 敏感操作实时通知 + - 异常模式检测 + +2. **日志分析** + - 操作趋势分析 + - 用户行为分析 + +3. **审计报告** + - 定期生成审计报告 + - 合规性报告 + +### 性能优化 + +1. **数据库优化** + - 分区表(按时间分区) + - 读写分离 + +2. **存储优化** + - 压缩旧日志 + - 冷热数据分离 + +## 风险评估和缓解措施 + +| 风险 | 影响 | 概率 | 缓解措施 | +|------|------|------|----------| +| 磁盘空间不足 | 日志记录失败 | 中 | 1. 设置磁盘监控告警
2. 实现自动清理
3. 使用日志轮转 | +| 性能影响 | API响应变慢 | 低 | 1. 异步处理
2. 批量写入
3. 性能测试 | +| 数据丢失 | 审计追溯失败 | 低 | 1. 双重存储(DB+文件)
2. 定期备份
3. 监控告警 | +| 安全风险 | 日志泄露 | 中 | 1. 严格权限控制
2. 日志加密存储
3. 访问审计 | + +## 成功标准 + +1. **功能完整性** + - 所有用户操作都被记录 + - 支持多条件查询 + - 支持日志导出 + +2. **性能指标** + - 日志记录延迟 < 100ms + - 查询响应时间 < 2s(1000条记录) + - 系统资源占用 < 5% + +3. **可靠性** + - 日志不丢失率 > 99.9% + - 自动清理任务成功率 100% + - 系统可用性 > 99.5% + +## 附录 + +### A. 文件清单 + +``` +backend/ +├── app/ +│ ├── models/ +│ │ └── audit_log.py # 审计日志模型 +│ ├── middleware/ +│ │ └── audit_middleware.py # 审计中间件 +│ ├── services/ +│ │ └── audit_service.py # 审计服务 +│ ├── tasks/ +│ │ └── audit_tasks.py # Celery任务 +│ ├── api/ +│ │ └── v1/ +│ │ └── audit.py # 审计API +│ └── core/ +│ └── config.py # 配置更新 +├── alembic/ +│ └── versions/ # 数据库迁移文件 +└── tests/ + └── test_audit.py # 测试文件 + +frontend/ +└── src/ + ├── views/ + │ └── AuditLogView.vue # 审计日志页面 + ├── api/ + │ └── audit.js # 审计API调用 + └── stores/ + └── audit.js # 审计状态管理 +``` + +### B. 依赖更新 + +**后端依赖**: +```txt +# requirements.txt 新增 +python-json-logger==2.0.7 +celery==5.3.4 +redis==5.0.1 +``` + +**前端依赖**: +```json +// package.json 新增 +"date-fns": "^3.0.0", +"xlsx": "^0.18.5" +``` + +### C. 部署检查清单 + +- [ ] 数据库迁移已执行 +- [ ] 环境变量已配置 +- [ ] Docker卷映射已更新 +- [ ] 目录权限已设置 +- [ ] Celery worker已启动 +- [ ] 清理任务已调度 +- \ No newline at end of file diff --git a/库存管理功能设计方案.md b/库存管理功能设计方案.md new file mode 100644 index 0000000..7053642 --- /dev/null +++ b/库存管理功能设计方案.md @@ -0,0 +1,532 @@ +# H3C ONU设备管理系统 - 库存管理功能设计方案 + +## 项目背景 +基于与项目负责人的详细讨论,为H3C ONU设备管理系统增加库存管理功能,用于管理项目中现有的实体设备(ONU、OLT、交换机、防火墙、上网行为管理等)的出入库台账。 + +## 设计讨论记录 +**讨论时间**: 2026年4月5日 +**参与人员**: 阿森、小柚(AI助手) + +## 一、需求分析 + +### 1.1 核心需求 +1. **设备全生命周期管理**:从采购入库 → 领用出库 → 安装使用 → 退库归还 → 报废处理 +2. **物料分类管理**:支持ONU设备、OLT设备、交换机、防火墙、上网行为管理等 +3. **库存台账管理**:记录基本信息、唯一标识、库存数量、位置信息、状态信息 +4. **业务流程管理**:批量入库、领用出库、退库归还、报废/报修处理、盘点调整 +5. **系统集成需求**:与现有设备监控系统深度集成,设备安装时更新位置信息,报废时从监控系统移除 + +### 1.2 用户场景 +1. **仓库管理员**:管理物料入库、出库、盘点 +2. **运维人员**:领用设备进行现场安装 +3. **采购人员**:新设备采购入库 +4. **管理人员**:查看库存报表,进行决策 + +### 1.3 功能范围 +- 物料分类管理 +- 库存台账管理 +- 出入库流程管理 +- 盘点管理 +- 报表统计 +- 与现有系统集成 + +## 二、整体架构设计 + +### 2.1 架构方案:集成扩展方案 +**选择理由**: +1. **无缝集成**:与现有设备监控系统深度集成 +2. **数据一致**:共享用户、权限、设备基础数据 +3. **流程连贯**:设备完整生命周期管理 +4. **技术复用**:复用现有的Excel导入、权限控制、UI组件 + +### 2.2 系统关系图 +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 库存管理系统 │◄────►│ 设备监控系统 │◄────►│ 权限管理系统 │ +│ │ │ │ │ (Casdoor) │ +├─────────────────┤ ├─────────────────┤ └─────────────────┘ +│ • 物料管理 │ │ • ONU状态监控 │ +│ • 出入库台账 │ │ • OLT管理 │ +│ • 库存统计 │ │ • 实时告警 │ +│ • 领用审批 │ │ • 历史记录 │ +└─────────────────┘ └─────────────────┘ + │ │ + └─────────────────────────┘ + 设备生命周期流转 +``` + +### 2.3 技术架构 +- **后端**:在现有FastAPI基础上扩展库存管理模块 +- **前端**:在现有Vue 3基础上增加库存管理页面 +- **数据库**:在现有PostgreSQL中新增库存相关表 +- **权限**:集成现有权限系统,新增库存管理权限 +- **导入导出**:复用现有Excel导入组件 + +## 三、详细功能设计 + +### 3.1 物料分类管理 +#### 支持的设备类型: +1. ONU设备(与现有系统深度集成) +2. OLT设备(与现有OLT管理集成) +3. 交换机 +4. 防火墙 +5. 上网行为管理 +6. 光模块等配件 + +#### 物料属性: +- **基本信息**:名称、型号、规格、品牌、单位 +- **唯一标识**: + - ONU设备:MAC地址(与现有系统一致) + - OLT设备:IP地址 + 序列号 + - 交换机:MAC地址 + 序列号 + - 防火墙:序列号 + 资产编号 + - 上网行为管理:序列号 +- **库存信息**:当前数量、安全库存 +- **位置信息**:仓库位置(项目专用仓库,无货架管理) +- **状态信息**:全新、已使用、待维修、报废 +- **供应商信息**:供应商、采购日期、采购价格 + +### 3.2 库存管理方式 +采用**混合管理模式**: +- **高价值设备**:序列号管理(每个设备单独跟踪) +- **低价值配件**:批次管理(按采购批次跟踪) +- **简单数量管理**:只记录总数(适用于消耗品) + +### 3.3 出入库流程设计 + +#### A. 采购入库流程 +``` +Excel模板导入 → 批量入库 → 生成采购单 → 审核入库 → 更新库存 +``` +- 支持现有Excel导入方式 +- 自动生成采购单编号 +- 支持分批入库 + +#### B. 领用出库流程 +``` +选择物料 → 填写领用信息 → 登记安装信息 → 确认出库 → 同步到监控系统 +``` +- 管理员直接操作,无需审批流程 +- 领用时预填安装信息(学校、楼宇、房间等) +- 自动创建监控设备记录 + +#### C. 退库归还流程 +``` +选择退库设备 → 选择退库类型 → 更新状态 → 退回库存 +``` +支持四种退库类型: +1. **简单退库**:状态变回库存,保留历史记录 +2. **维修退库**:标记为待维修,进入维修流程 +3. **报废退库**:进入报废流程,从监控系统移除 +4. **带历史退库**:保留完整的安装和使用历史 + +### 3.4 库存管理功能 + +#### A. 库存总览 +- 按设备类型统计数量 +- 库存价值统计 +- 库存状态分布(全新/已使用/待维修) +- 低库存预警 + +#### B. 定期盘点 +- 每月/每季度全盘 +- 盘点差异记录 +- 盘点报告生成 +- 库存数量调整 + +#### C. 报表统计 +1. **库存总览报表**:各类物料数量和价值统计 +2. **领用统计报表**:按人员、项目、时间统计 +3. **库存预警报表**:低于安全库存的物料提醒 +4. **设备生命周期报告**:设备从入库到报废的全过程 + +### 3.5 状态流转设计 +``` +库存中 (in_stock) + ↓ 领用 +已分配 (allocated) + ↓ 安装 +已安装 (installed) + ↓ 投入使用 +使用中 (in_use) + ├─→ 简单退库 → 库存中 + ├─→ 维修退库 → 维修中 (repairing) → 维修完成 → 库存中 + └─→ 报废退库 → 已报废 (scrapped) +``` + +## 四、数据库设计 + +### 4.1 核心数据表 + +```sql +-- 物料分类表 +CREATE TABLE material_categories ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, -- 分类名称:ONU、OLT、交换机等 + code VARCHAR(50) UNIQUE NOT NULL, -- 分类代码 + description TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 物料主数据表 +CREATE TABLE materials ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT REFERENCES material_categories(id), + name VARCHAR(200) NOT NULL, -- 物料名称 + model VARCHAR(100), -- 型号 + specification TEXT, -- 规格 + brand VARCHAR(100), -- 品牌 + unit VARCHAR(20) DEFAULT '个', -- 单位 + safe_quantity INTEGER DEFAULT 0, -- 安全库存 + notes TEXT, -- 备注 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 库存批次表(批次管理) +CREATE TABLE inventory_batches ( + id BIGSERIAL PRIMARY KEY, + material_id BIGINT REFERENCES materials(id), + batch_no VARCHAR(50) NOT NULL, -- 批次号 + quantity INTEGER NOT NULL, -- 批次数量 + supplier VARCHAR(200), -- 供应商 + purchase_date DATE, -- 采购日期 + purchase_price DECIMAL(10,2), -- 采购单价 + expiry_date DATE, -- 有效期(如有) + location VARCHAR(100), -- 仓库位置 + status VARCHAR(20) DEFAULT 'in_stock', -- 状态:in_stock, reserved, out_of_stock + created_at TIMESTAMP DEFAULT NOW() +); + +-- 序列号设备表(高价值设备单独跟踪) +CREATE TABLE serial_devices ( + id BIGSERIAL PRIMARY KEY, + material_id BIGINT REFERENCES materials(id), + batch_id BIGINT REFERENCES inventory_batches(id), + serial_no VARCHAR(100) NOT NULL UNIQUE, -- 序列号 + mac_address VARCHAR(17), -- MAC地址(ONU设备) + asset_no VARCHAR(50), -- 资产编号 + status VARCHAR(20) DEFAULT 'in_stock', -- 状态:in_stock, allocated, installed, in_use, returned, repairing, scrapped + current_location VARCHAR(200), -- 当前位置 + installed_info JSONB, -- 安装信息:学校、楼宇、房间等 + onu_device_id BIGINT REFERENCES onu_devices(id), -- 关联监控设备 + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 出入库记录表 +CREATE TABLE inventory_transactions ( + id BIGSERIAL PRIMARY KEY, + transaction_no VARCHAR(50) UNIQUE NOT NULL, -- 单据编号 + transaction_type VARCHAR(20) NOT NULL, -- 类型:purchase_in, allocate_out, return_in, scrap_out, adjust + material_id BIGINT REFERENCES materials(id), + batch_id BIGINT REFERENCES inventory_batches(id), + serial_device_id BIGINT REFERENCES serial_devices(id), + quantity INTEGER NOT NULL, + from_status VARCHAR(20), -- 操作前状态 + to_status VARCHAR(20), -- 操作后状态 + operator_id BIGINT REFERENCES users(id), -- 操作人 + project_name VARCHAR(200), -- 项目名称 + installation_info JSONB, -- 安装信息 + notes TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 盘点记录表 +CREATE TABLE inventory_checks ( + id BIGSERIAL PRIMARY KEY, + check_no VARCHAR(50) UNIQUE NOT NULL, -- 盘点单号 + check_date DATE NOT NULL, -- 盘点日期 + checker_id BIGINT REFERENCES users(id), -- 盘点人 + material_id BIGINT REFERENCES materials(id), + batch_id BIGINT REFERENCES inventory_batches(id), + book_quantity INTEGER, -- 账面数量 + actual_quantity INTEGER, -- 实际数量 + difference INTEGER, -- 差异数量 + reason TEXT, -- 差异原因 + adjusted BOOLEAN DEFAULT FALSE, -- 是否已调整 + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 4.2 数据关系图 +``` +material_categories + ↑ +materials ─┬─ inventory_batches + │ + └─ serial_devices ───── onu_devices (现有表) + ↑ + inventory_transactions + ↑ + inventory_checks +``` + +## 五、与现有系统集成设计 + +### 5.1 设备流转集成 +```python +# 领用出库时自动创建监控设备 +def allocate_device(serial_device_id, installation_info): + # 1. 更新库存设备状态为 allocated + # 2. 如果设备类型是ONU,自动创建onu_devices记录 + # 3. 关联serial_devices.onu_device_id + # 4. 初始化设备状态检查任务 + +# 安装完成时同步到监控系统 +def install_device(serial_device_id, onu_device_id): + # 1. 更新库存设备状态为 installed + # 2. 更新onu_devices的安装位置信息 + # 3. 开始定期状态监控 + +# 退库时处理监控设备 +def return_device(serial_device_id, return_type): + if return_type == 'scrap': + # 报废:从监控系统移除,保留历史记录 + deactivate_monitoring(serial_device_id) + else: + # 其他退库:暂停监控,设备状态标记为退库 + pause_monitoring(serial_device_id) +``` + +### 5.2 权限集成 +- 复用现有Casdoor用户体系 +- 新增库存管理相关权限: + - `inventory:view` - 查看库存 + - `inventory:manage` - 管理物料 + - `inventory:transaction` - 出入库操作 + - `inventory:check` - 盘点操作 + - `inventory:report` - 查看报表 + +### 5.3 数据导入集成 +- 复用现有的Excel导入组件 +- 新增库存导入模板 +- 支持批量设备入库 + +## 六、用户界面设计 + +### 6.1 桌面端界面布局 +``` +┌─────────────────────────────────────┐ +│ 库存管理 │ +├─────────────────────────────────────┤ +│ [物料管理] [入库管理] [出库管理] │ +│ [退库管理] [盘点管理] [报表统计] │ +├─────────────────────────────────────┤ +│ 主工作区 │ +│ • 库存总览仪表板 │ +│ • 物料列表(表格/卡片) │ +│ • 出入库流水 │ +└─────────────────────────────────────┘ +``` + +### 6.2 移动端适配 +- 复用移动端设计思路中的底部导航 +- 增加"库存"标签页 +- 移动端聚焦功能:库存查询、扫码盘点、快速领用 + +### 6.3 关键页面设计 +1. **库存总览页面**:卡片式数据可视化,关键指标一目了然 +2. **物料管理页面**:支持表格和卡片视图,可按分类筛选 +3. **领用出库页面**:向导式操作流程,简化用户操作 +4. **盘点管理页面**:支持移动端扫码,提高盘点效率 + +## 七、API接口设计 + +### 7.1 物料管理接口 +``` +GET /api/inventory/materials # 获取物料列表 +POST /api/inventory/materials # 创建物料 +GET /api/inventory/materials/{id} # 获取物料详情 +PUT /api/inventory/materials/{id} # 更新物料 +DELETE /api/inventory/materials/{id} # 删除物料 +POST /api/inventory/materials/import # Excel导入物料 +GET /api/inventory/materials/export # 导出物料列表 +``` + +### 7.2 出入库接口 +``` +POST /api/inventory/transactions/purchase # 采购入库 +POST /api/inventory/transactions/allocate # 领用出库 +POST /api/inventory/transactions/return # 退库归还 +POST /api/inventory/transactions/scrap # 报废处理 +GET /api/inventory/transactions # 查询出入库记录 +GET /api/inventory/transactions/{id} # 获取单据详情 +``` + +### 7.3 库存查询接口 +``` +GET /api/inventory/summary # 库存总览 +GET /api/inventory/status # 库存状态统计 +GET /api/inventory/warning # 库存预警列表 +GET /api/inventory/history/{device_id} # 设备流转历史 +``` + +### 7.4 盘点管理接口 +``` +POST /api/inventory/checks # 创建盘点单 +PUT /api/inventory/checks/{id} # 更新盘点结果 +POST /api/inventory/checks/{id}/adjust # 执行库存调整 +GET /api/inventory/checks # 查询盘点记录 +GET /api/inventory/checks/{id}/report # 生成盘点报告 +``` + +## 八、实施计划 + +### 第一阶段:基础框架(1-2周) +1. 数据库表结构创建和迁移脚本 +2. 后端API基础框架搭建 +3. 权限系统扩展(新增库存管理权限) +4. 基础物料管理功能实现 + +**交付物**: +- 数据库迁移文件 +- 后端基础API +- 权限配置更新 + +### 第二阶段:核心功能(2-3周) +1. 出入库流程完整实现 +2. Excel导入导出功能 +3. 库存统计报表 +4. 与监控系统集成开发 + +**交付物**: +- 完整的出入库功能 +- Excel导入模板 +- 库存报表功能 +- 设备流转集成 + +### 第三阶段:高级功能(1-2周) +1. 移动端盘点功能 +2. 库存预警系统 +3. 设备生命周期报告 +4. 性能优化和测试 + +**交付物**: +- 移动端盘点支持 +- 预警系统 +- 完整测试报告 + +## 九、技术实现要点 + +### 9.1 后端实现 +- 新增`inventory`模块,目录结构: + ``` + backend/app/ + ├── api/ + │ └── inventory/ # 库存管理API + ├── models/ + │ └── inventory.py # 库存数据模型 + ├── schemas/ + │ └── inventory.py # 库存Pydantic模式 + ├── services/ + │ └── inventory_service.py # 库存业务逻辑 + └── tasks/ + └── inventory_tasks.py # 库存相关异步任务 + ``` + +### 9.2 前端实现 +- 新增库存管理路由和页面 +- 复用现有的Element Plus组件 +- 开发专用的库存管理组件 + +### 9.3 数据一致性保证 +- 使用数据库事务保证库存操作的原子性 +- 定期数据一致性检查任务 +- 完整的操作日志记录 + +## 十、测试策略 + +### 10.1 功能测试 +1. **单元测试**:每个API接口的独立测试 +2. **集成测试**:库存流程与监控系统集成测试 +3. **业务流程测试**:完整的出入库流程测试 +4. **数据一致性测试**:库存数量与交易记录一致性验证 + +### 10.2 性能测试 +1. **并发测试**:多用户同时操作库存 +2. **大数据量测试**:导入大量设备数据 +3. **响应时间测试**:关键操作响应时间 + +### 10.3 兼容性测试 +1. **浏览器兼容**:主流浏览器测试 +2. **移动端兼容**:手机浏览器测试 +3. **Excel兼容**:不同版本Excel导入测试 + +## 十一、风险与应对 + +### 11.1 技术风险 +1. **数据一致性风险**:采用数据库事务和定期检查 +2. **性能风险**:大数据量时使用分页和缓存 +3. **集成风险**:分阶段集成,先读后写 + +### 11.2 业务风险 +1. **流程变更风险**:设计灵活的流程配置 +2. **用户接受度风险**:提供培训和使用文档 +3. **数据迁移风险**:制定详细的数据迁移计划 + +## 十二、维护与支持 + +### 12.1 监控指标 +1. **库存操作成功率**:出入库操作成功比例 +2. **数据一致性指标**:库存数量与实际数量差异 +3. **用户使用指标**:各功能使用频率 + +### 12.2 运维支持 +1. **日志记录**:详细的操作日志 +2. **数据备份**:定期库存数据备份 +3. **故障恢复**:数据不一致时的恢复流程 + +## 十三、预期效益 + +### 13.1 业务效益 +1. **流程规范化**:设备从采购到报废的全生命周期管理 +2. **库存可视化**:实时掌握库存状态,避免缺料和积压 +3. **成本控制**:精确的设备资产管理和成本核算 +4. **效率提升**:减少人工盘点时间,提高领用效率 + +### 13.2 管理效益 +1. **决策支持**:基于数据的采购和库存优化决策 +2. **责任追溯**:完整的设备流转历史记录 +3. **合规管理**:符合资产管理规范要求 + +## 十四、后续扩展考虑 + +### 14.1 功能扩展 +1. **供应商管理**:完整的供应商信息管理 +2. **采购管理**:采购申请、比价、合同管理 +3. **维修管理**:设备维修流程管理 +4. **租赁管理**:设备租赁和归还管理 + +### 14.2 技术扩展 +1. **条码/RFID支持**:自动化库存管理 +2. **移动APP**:独立的库存管理APP +3. **API开放**:对外提供库存查询API +4. **数据分析**:基于AI的库存预测 + +## 十五、总结 + +本设计方案为H3C ONU设备管理系统增加了完整的库存管理功能,实现了设备从采购入库到报废退库的全生命周期管理。方案具有以下特点: + +1. **深度集成**:与现有设备监控系统无缝集成 +2. **流程完整**:覆盖采购、领用、安装、退库、报废全流程 +3. **灵活配置**:支持不同设备类型的差异化管理 +4. **用户友好**:简洁的界面和操作流程 +5. **可扩展性强**:为后续功能扩展预留接口 + +该方案的实施将显著提升项目的设备管理水平和运营效率,为项目的长期发展奠定坚实基础。 + +--- + +**文档信息**: +- 创建时间:2026年4月5日 +- 文档版本:v1.0 +- 适用对象:开发团队、测试团队、运维团队 +- 保密级别:内部使用 + +**备注**: +1. 本方案基于与项目负责人的详细讨论结果整理 +2. 实施过程中可根据实际情况进行调整 +3. 建议分阶段实施,降低风险 +4. 重要变更需更新本文档并通知相关人员 \ No newline at end of file diff --git a/移动端设计思路.md b/移动端设计思路.md new file mode 100644 index 0000000..c91ce4f --- /dev/null +++ b/移动端设计思路.md @@ -0,0 +1,317 @@ +# H3C ONU设备管理系统 - 移动端适配设计思路 + +## 项目背景 +当前H3C ONU设备管理系统主要针对桌面端设计,在移动设备上存在以下问题: +1. 布局在小屏幕上显示不全,需要左右滚动 +2. 按钮和交互元素太小,难以点击 +3. 表格数据在小屏幕上难以阅读 +4. 导航菜单在手机上使用不便 +5. 响应式断点设置不合理 + +## 设计目标 +为移动端用户提供专用的小屏幕优化布局,聚焦核心功能,提升现场维护、外出监控和应急处理场景下的使用体验。 + +## 用户场景分析 +移动端用户主要使用场景: +1. **现场维护**:快速查看设备状态,执行简单操作 +2. **外出监控**:随时了解整体运行情况 +3. **应急处理**:进行紧急操作和故障排查 + +## 核心功能范围 +移动端聚焦以下三个核心功能: +1. **统计预览** - 了解整体运行状态 +2. **设备列表** - 查找和管理具体设备 +3. **OLT管理** - 管理OLT设备和端口 + +**功能精简**:移动端暂不需要新增、删除设备等复杂管理功能。 + +## 整体设计方案 + +### 一、架构方案:混合响应式设计 +- **独立移动端布局**:为小屏幕(<768px)设计专用布局 +- **组件复用**:复用现有Vue组件和业务逻辑 +- **渐进增强**:桌面端功能完整,移动端聚焦核心 +- **主题继承**:完全支持现有的深色/浅色主题切换 + +### 二、技术实现方案 +推荐使用**方案A:响应式断点 + 条件渲染** + +```vue + + + +``` + +## 详细设计说明 + +### 1. 导航系统 - 底部导航栏 +``` +[统计] [设备] [OLT] [我的] +``` + +**设计要点:** +- 固定底部,符合移动端使用习惯 +- 图标+文字,清晰易懂 +- 当前选中项高亮显示 +- 支持徽章提示(如设备告警数量) + +### 2. 设备列表 - 卡片式设计 + +#### 卡片内容(折叠状态): +``` +┌─────────────────────────┐ +│ MAC: aabb-ccdd-eeee ● 在线 │ +│ 学校: 第一中学 │ +│ 楼宇: 教学楼A-301 │ +│ 场所:计算机教室 │ +│ 房间号:201 │ +└─────────────────────────┘ +``` + +#### 点击展开后拓展显示: +``` +┌─────────────────────────┐ +│ MAC: aabb-ccdd-eeee ● 在线 │ +│ 学校: 第一中学 │ +│ 楼宇: 教学楼A-301 │ +│ 场所:计算机教室 │ +│ 房间号:201 │ +├─────────────────────────┤ +│ [更新] [编辑] [业务下发] │ +├─────────────────────────┤ +│ 距离: 1235M │ +│ 端口: Onu2/0/2:15 │ +│ 型号:WA6520H-EGPON/A │ +│ 所属 OLT:七百弄乡中心机房 │ +│ 备注:由于是微机室,需配置 │ +│ 设备1000M带宽限速 │ +└─────────────────────────┘ +``` + +**设计优势:** +- 信息层次清晰,重点突出(MAC、在线状态、学校名称、楼宇、场所、房间号) +- 节省屏幕空间 +- 操作按钮在展开后显示,避免误触 + +### 3. 统计预览 - 可折叠面板 +``` +┌─────────────────────────┐ +│ 📊 统计概览 │ +│ ▼ 总体状态 │ +│ 在线: 3852/4000 (96%) │ +│ 城区: 95% 城郊: 92% │ +│ 乡镇: 88% │ +├─────────────────────────┤ +│ ▶ 详细统计(点击展开) │ +├─────────────────────────┤ +│ ▶ 告警统计(点击展开) │ +└─────────────────────────┘ +``` + +**功能特点:** +- 默认显示关键指标 +- 详细内容可折叠,按需展开 +- 支持按区域/学校筛选 +- 支持时间范围选择 + +### 4. OLT管理 - 卡片+展开式 + +#### 卡片内容: +``` +┌─────────────────────────┐ +│ 192.168.1.100 ● 正常 │ +│ 安装位置: 岩滩接入网机房 │ +│ 环路: 0个 [检测] │ +├─────────────────────────┤ +│ [快速扫描] [端口管理] [编辑] │ +└─────────────────────────┘ +``` + +**功能分层:** +- 卡片显示:IP地址、环路检测状态、快速扫描按钮、端口管理按钮、编辑按钮 + +## 视觉设计规范 + +### 1. 主题继承 +- 完全支持现有的深色/浅色两种主题风格 +- 用户可自由切换主题 +- 保持一致的品牌视觉语言 + +### 2. 设计原则 +- **触摸友好**:最小点击区域44×44px +- **信息密度**:适当减少信息密度,避免拥挤 +- **操作简化**:复杂操作分步骤引导 +- **反馈及时**:操作后提供明确反馈 + +### 3. 响应式断点 +- **移动端**:< 768px +- **平板端**:768px - 1024px(可考虑适配) +- **桌面端**:> 1024px(现有布局) + +## 技术实施方案 + +### 第一阶段:基础框架(1-2天) +1. 添加移动端检测逻辑 +2. 创建底部导航组件 +3. 设置移动端专用路由 +4. 适配现有主题系统 + +### 第二阶段:核心页面(3-5天) +1. 设备卡片组件开发 +2. 统计折叠面板 +3. OLT管理卡片 +4. 响应式表格重构 + +### 第三阶段:交互优化(2-3天) +1. 触摸手势支持 +2. 加载状态优化 +3. 离线能力增强 +4. 性能优化 + +## 组件设计规范 + +### 1. 设备卡片组件 (DeviceCard.vue) +```vue + +``` + +### 2. 底部导航组件 (BottomNavigation.vue) +```vue + +``` + +## 性能优化策略 + +### 1. 数据加载优化 +- 分页加载设备列表 +- 虚拟滚动长列表 +- 图片和图标懒加载 + +### 2. 网络优化 +- API请求合并 +- 数据缓存策略 +- 离线模式支持 + +### 3. 渲染优化 +- 组件懒加载 +- 避免不必要的重渲染 +- 使用CSS动画代替JS动画 + +## 测试策略 + +### 1. 设备兼容性测试 +- iOS Safari +- Android Chrome +- 微信内置浏览器 +- 不同屏幕尺寸 + +### 2. 功能测试 +- 触摸操作测试 +- 手势支持测试 +- 网络切换测试 +- 主题切换测试 + +### 3. 性能测试 +- 页面加载时间 +- 内存使用情况 +- 滚动流畅度 +- 电池消耗 + +## 后续扩展考虑 + +### 1. 功能扩展 +- 推送通知(设备离线告警) +- 扫码快速定位设备 +- 语音搜索和操作 +- 离线数据同步 + +### 2. 体验优化 +- PWA支持(添加到主屏幕) +- 深色模式自动切换 +- 手势快捷操作 +- 自定义快捷功能 + +### 3. 平台扩展 +- 微信小程序版本 +- 企业微信集成 +- 钉钉工作台集成 +- 移动端APP + +## 总结 + +本设计方案通过重新规划移动端专用布局,采用卡片式设计、底部导航、可折叠面板等移动端友好模式,解决了当前系统在移动设备上的可用性问题。方案既保持了与现有系统的功能一致性,又针对移动端使用场景进行了优化,能够显著提升移动端用户体验。 + +**核心价值:** +1. **专注核心功能**:聚焦统计、设备、OLT三大核心场景 +2. **优化交互体验**:卡片式设计更适合触摸操作 +3. **保持系统一致性**:复用现有组件和主题 +4. **可扩展性强**:为后续功能扩展预留空间 + +--- + +*文档创建时间:2026年4月4日* +*基于与项目负责人的详细讨论结果整理* + +1. 删除物料报错 inventory.js:12 DELETE http://10.10.10.14:5173/api/inventory/materials/1 400 (Bad Request) +2. 出入库记录点击单据编号应该可以看到出库或者入库单填写时的详细信息 + +1. 因为“数据导入”功能只导入了设备数据,所以不需要单独做一个页面了,改为在设备列表页面添加一个“数据导入”按钮,点击按钮后打开一个文件选择框,选择文件后进行数据导入(提供导入文件模板)。 +2. 给现有的OLT列表也添加一个区域(和设备列表的区域内容同步,默认先选择城区,后续我再自己修改),并且设置区域权限,区域管理员只能管理对应区域的OLT。 + +1. 我发现现在的30分钟定时检查好像有点问题,还不是触发完了之后所有设备都显示离线,请帮我排查一下 +2. 新增一个设置,超级管理员可以设置定时检查时间,默认是30分钟,用户可以自己设置,但是不能小于5分钟。 + + +OLT管理页面的“区域”是需要跟用户管理页面中,区域管理员的“分配区域”是同步的,因为区域管理员应该只能查看到自己区域内的OLT,现在OLT管理页面的区域只有三个选项,这是不对的。 \ No newline at end of file