diff --git a/TempLate/GERP/index.html b/TempLate/GERP/index.html
index eea824b0..e69d33e9 100644
--- a/TempLate/GERP/index.html
+++ b/TempLate/GERP/index.html
@@ -5,7 +5,7 @@
GERP 管理系统
-
+
@@ -14,7 +14,7 @@
-
+
diff --git a/TempLate/index.html b/TempLate/index.html
index 5c5602ba..50f7142f 100644
--- a/TempLate/index.html
+++ b/TempLate/index.html
@@ -5,8 +5,8 @@
银河锋锐软件开发工作室
-
-
+
+
diff --git a/backup/www/index.html b/backup/www/index.html
index ca1f54c6..2b4a0504 100644
--- a/backup/www/index.html
+++ b/backup/www/index.html
@@ -5,8 +5,8 @@
银河锋锐软件开发工作室
-
-
+
+
diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py
index e9d82831..2ea0961c 100644
--- a/gvsds/apps/gerp/views/__init__.py
+++ b/gvsds/apps/gerp/views/__init__.py
@@ -155,3 +155,10 @@ from .app import (
AppApproveView,
AppPendingView,
)
+from .system_ops import (
+ GERPSystemStatusView,
+ GERPSystemSchedulesView,
+ GERPSystemScheduleTriggerView,
+ GERPSystemSecretsView,
+ GERPSystemRuntimeView,
+)
diff --git a/gvsds/apps/gerp/views/menu.py b/gvsds/apps/gerp/views/menu.py
index 4532f35a..3b186ea5 100644
--- a/gvsds/apps/gerp/views/menu.py
+++ b/gvsds/apps/gerp/views/menu.py
@@ -120,19 +120,31 @@ class MenuListCompatView(ELTAuthenticatedAPIView):
menu_map.pop(uuid_str, None)
# 权限过滤:父节点无权限则整个子树隐藏(避免子节点提升为根导致菜单结构混乱)
- if not is_admin:
- no_perm_uuids = set()
- for uuid_str, item in menu_map.items():
- perm_level = item['PermLevel']
- perm_attr_str = item['PermAttr']
- perm_attrs = [a.strip() for a in perm_attr_str.split(';') if a.strip()] if perm_attr_str else []
- if perm_level > 0 and perm_attrs:
- try:
- if not PermissionVerifier(perm_level, perm_attrs, user, request):
- no_perm_uuids.add(uuid_str)
- except Exception:
- logger.exception('MenuListCompatView 权限校验异常 uuid=%s', uuid_str)
+ # admin:super 专属菜单(如 GERP系统)对所有用户生效:即使 is_admin(admin:tenant)
+ # 也要按 IsSuperAdmin 校验,防止租户管理员看到系统级管理入口。
+ no_perm_uuids = set()
+ for uuid_str, item in menu_map.items():
+ perm_level = item['PermLevel']
+ perm_attr_str = item['PermAttr']
+ perm_attrs = [a.strip() for a in perm_attr_str.split(';') if a.strip()] if perm_attr_str else []
+ if not perm_attrs:
+ continue
+ if 'admin:super' in perm_attrs:
+ from gvsdsdk.auth.admin_service import IsSuperAdmin
+ try:
+ if not IsSuperAdmin(request=request, user=user, role_uuid=role_uuid_bytes):
no_perm_uuids.add(uuid_str)
+ except Exception:
+ logger.exception('MenuListCompatView admin:super 菜单校验异常 uuid=%s', uuid_str)
+ no_perm_uuids.add(uuid_str)
+ continue
+ if not is_admin and perm_level > 0:
+ try:
+ if not PermissionVerifier(perm_level, perm_attrs, user, request):
+ no_perm_uuids.add(uuid_str)
+ except Exception:
+ logger.exception('MenuListCompatView 权限校验异常 uuid=%s', uuid_str)
+ no_perm_uuids.add(uuid_str)
# 收集所有无权限节点的子孙节点
def _CollectDescendants(parent_uuid, acc):
for uid, item in menu_map.items():
diff --git a/gvsds/apps/gerp/views/system_ops.py b/gvsds/apps/gerp/views/system_ops.py
new file mode 100644
index 00000000..e8a54969
--- /dev/null
+++ b/gvsds/apps/gerp/views/system_ops.py
@@ -0,0 +1,392 @@
+"""模块:GERP系统 运维管理 API
+
+系统管理员(admin:super)专属的系统级运维能力——只放"所有人现在都管不了的东西":
+ 1. 系统状态:运行时间、进程、版本、Celery / Redis / 数据库健康
+ 2. 系统时序:Celery beat 调度列表 + 手动触发
+ 3. secrets.ini:配置查看(脱敏)与键值编辑
+ 4. 运行时调整:日志级别热切换(持久化到 app.ini)
+
+所有接口均要求当前角色拥有 admin:super 权限(跨租户管理员),
+admin:tenant(租户内管理员)与普通用户一律拒绝。
+
+所有命名遵循帕斯卡命名法。
+"""
+
+import logging
+import platform
+import time
+from pathlib import Path
+
+from rest_framework.response import Response
+
+from . import ELTAuthenticatedAPIView
+
+logger = logging.getLogger(__name__)
+
+# secrets.ini / app.ini 位于项目根目录(与 gvsds/secrets.py 的定位一致)
+_BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent
+_SECRETS_FILE = _BASE_DIR / 'secrets.ini'
+_APP_INI_FILE = _BASE_DIR / 'app.ini'
+
+# 敏感键名判定:键名包含以下关键字时,读取值一律脱敏为占位符
+_SENSITIVE_KEYWORDS = ('password', 'secret', 'key', 'token', 'private')
+# 脱敏占位符:POST 提交该值时视为"未修改",跳过写入
+_MASK = '******'
+
+# 可手动触发的系统定时任务注册表(与 gvsds/settings.py CELERY_BEAT_SCHEDULE 对应)
+_TRIGGERABLE_TASKS = {
+ 'ResetDailyStats': 'gvsds.apps.stats.tasks.ResetDailyStats',
+ 'ResetMonthlyStats': 'gvsds.apps.stats.tasks.ResetMonthlyStats',
+ 'PurgeOldStats': 'gvsds.apps.stats.tasks.PurgeOldStats',
+ 'BuildDailyRankings': 'gvsds.apps.stats.tasks.BuildDailyRankings',
+}
+
+
+def _IsSuperAdmin(request):
+ """判断当前用户是否为系统管理员(仅 admin:super)"""
+ from gvsdsdk.auth.admin_service import IsSuperAdmin
+ return IsSuperAdmin(request)
+
+
+def _Denied():
+ return Response({'code': 403, 'msg': '仅系统管理员(admin:super)可访问', 'data': None}, status=403)
+
+
+def _IsSensitiveKey(key):
+ """键名是否敏感(值需脱敏显示)"""
+ k = (key or '').lower()
+ return any(kw in k for kw in _SENSITIVE_KEYWORDS)
+
+
+def _ReadIniStructure(path):
+ """读取 ini 文件为 {section: {key: value}},保持文件内顺序"""
+ import configparser
+ cfg = configparser.ConfigParser()
+ if path.exists():
+ cfg.read(str(path), encoding='utf-8')
+ result = {}
+ for section in cfg.sections():
+ result[section] = dict(cfg.items(section))
+ return result
+
+
+def _UpdateIniFile(path, updates):
+ """行级原位更新 ini 文件(保留注释与格式),返回实际更新的键列表
+
+ updates: {section: {key: value}}
+ - 键已存在:替换该行
+ - 键不存在:追加到所属 section 末尾(不新建重复 section)
+ """
+ if not path.exists():
+ return [], '配置文件不存在'
+ lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
+
+ # 第一遍:扫描各 section 的行区间 [start, end)
+ sections_range = {} # section -> (start_idx, end_idx)
+ current = None
+ start = 0
+ for i, line in enumerate(lines):
+ s = line.strip()
+ if s.startswith('[') and s.endswith(']'):
+ if current is not None:
+ sections_range[current] = (start, i)
+ current = s[1:-1].strip()
+ start = i
+ if current is not None:
+ sections_range[current] = (start, len(lines))
+
+ # 待更新集合:排除脱敏占位符(未修改)
+ pending = {}
+ for section, kv in updates.items():
+ for key, value in kv.items():
+ if isinstance(value, str) and value.strip() == _MASK:
+ continue
+ if key.strip():
+ pending[(str(section).strip(), key.strip())] = value
+
+ updated_keys = []
+ result_lines = []
+ current_section = None
+ for line in lines:
+ s = line.strip()
+ if s.startswith('[') and s.endswith(']'):
+ current_section = s[1:-1].strip()
+ result_lines.append(line)
+ continue
+ # 键值行(跳过注释与空行)
+ if current_section and '=' in s and not s.startswith(('#', ';')):
+ key = s.split('=', 1)[0].strip()
+ if (current_section, key) in pending:
+ result_lines.append(f'{key} = {pending.pop((current_section, key))}\n')
+ updated_keys.append(f'{current_section}.{key}')
+ continue
+ result_lines.append(line)
+
+ # 剩余未匹配的键:追加到所属 section 末尾
+ if pending:
+ # 按 section 分组
+ by_section = {}
+ for (section, key), value in pending.items():
+ by_section.setdefault(section, []).append((key, value))
+ # 从后往前插入,保证顺序
+ for section, kv_list in by_section.items():
+ if section in sections_range:
+ _, end = sections_range[section]
+ insert_idx = end
+ # 跳过 section 末尾的注释/空行,插到最后一个内容行后
+ while insert_idx > 0 and (not result_lines[insert_idx - 1].strip()
+ or result_lines[insert_idx - 1].lstrip().startswith(('#', ';'))):
+ insert_idx -= 1
+ for key, value in kv_list:
+ result_lines.insert(insert_idx, f'{key} = {value}\n')
+ insert_idx += 1
+ updated_keys.append(f'{section}.{key}')
+ else:
+ # section 不存在:追加新 section 块
+ result_lines.append(f'\n[{section}]\n')
+ for key, value in kv_list:
+ result_lines.append(f'{key} = {value}\n')
+ updated_keys.append(f'{section}.{key}')
+
+ path.write_text(''.join(result_lines), encoding='utf-8')
+ return updated_keys, None
+
+
+class GERPSystemStatusView(ELTAuthenticatedAPIView):
+ """系统状态总览——运行时间、进程、版本、依赖服务健康
+
+ GET /API/GERPSystem/Status
+ """
+
+ def get(self, request):
+ if not _IsSuperAdmin(request):
+ return _Denied()
+
+ import django
+ import psutil
+ from django.db import connection
+
+ proc = psutil.Process()
+ data = {
+ 'Host': platform.node(),
+ 'PID': proc.pid,
+ 'StartTime': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(proc.create_time())),
+ 'UptimeSeconds': int(time.time() - proc.create_time()),
+ 'BootTime': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(psutil.boot_time())),
+ 'Python': platform.python_version(),
+ 'Django': django.get_version(),
+ 'Gvsdsdk': '',
+ 'MemoryMB': round(proc.memory_info().rss / 1024 / 1024, 1),
+ 'CPUPercent': proc.cpu_percent(interval=0.1),
+ 'Redis': 'unknown',
+ 'Database': 'unknown',
+ 'Celery': 'disabled',
+ 'LogLevel': logging.getLogger('gvsds').getEffectiveLevel(),
+ }
+ try:
+ import gvsdsdk
+ data['Gvsdsdk'] = gvsdsdk.get_version_info().get('Version', '')
+ except Exception:
+ pass
+
+ # Redis 健康
+ try:
+ from django.conf import settings as _s
+ import redis as _redis
+ _r = _redis.Redis(
+ host=_s.REDIS_HOST, port=_s.REDIS_PORT, db=_s.REDIS_DB,
+ password=_s.REDIS_PASSWORD or None, socket_timeout=2,
+ )
+ data['Redis'] = 'ok' if _r.ping() else 'error'
+ except Exception:
+ data['Redis'] = 'error'
+
+ # 数据库健康
+ try:
+ with connection.cursor() as cur:
+ cur.execute('SELECT 1')
+ data['Database'] = 'ok'
+ except Exception:
+ data['Database'] = 'error'
+
+ # Celery 启用状态
+ try:
+ from django.conf import settings as _s
+ if getattr(_s, 'CELERY_BROKER_URL', None):
+ data['Celery'] = 'enabled'
+ except Exception:
+ pass
+
+ # 日志级别转为名称
+ data['LogLevel'] = logging.getLevelName(data['LogLevel'])
+ return Response({'code': 0, 'data': data})
+
+
+class GERPSystemSchedulesView(ELTAuthenticatedAPIView):
+ """系统时序——Celery beat 调度列表
+
+ GET /API/GERPSystem/Schedules
+ """
+
+ def get(self, request):
+ if not _IsSuperAdmin(request):
+ return _Denied()
+
+ schedules = []
+ try:
+ from django.conf import settings as _s
+ beat = getattr(_s, 'CELERY_BEAT_SCHEDULE', None) or {}
+ for name, cfg in beat.items():
+ schedules.append({
+ 'Name': name,
+ 'Task': cfg.get('task', ''),
+ 'Schedule': str(cfg.get('schedule', '')),
+ 'Enabled': True,
+ })
+ except Exception:
+ logger.exception('GERPSystemSchedulesView 读取 beat schedule 失败')
+
+ triggerable = [
+ {'Name': n, 'Task': t} for n, t in _TRIGGERABLE_TASKS.items()
+ ]
+ return Response({'code': 0, 'data': {'Schedules': schedules, 'Triggerable': triggerable}})
+
+
+class GERPSystemScheduleTriggerView(ELTAuthenticatedAPIView):
+ """手动触发系统定时任务
+
+ POST /API/GERPSystem/Schedules/Trigger Body: {Name: ResetDailyStats}
+ - 有 Celery:异步投递(delay)
+ - 无 Celery:同步执行(apply)
+ """
+
+ def post(self, request):
+ if not _IsSuperAdmin(request):
+ return _Denied()
+
+ name = (request.data.get('Name') or '').strip()
+ task_path = _TRIGGERABLE_TASKS.get(name)
+ if not task_path:
+ return Response({'code': 1, 'msg': f'未知任务 {name}', 'data': None}, status=400)
+
+ try:
+ module_path, func_name = task_path.rsplit('.', 1)
+ import importlib
+ module = importlib.import_module(module_path)
+ task = getattr(module, func_name)
+ from django.conf import settings as _s
+ if getattr(_s, 'CELERY_BROKER_URL', None) and getattr(task, 'delay', None):
+ result = task.delay()
+ return Response({'code': 0, 'msg': f'任务 {name} 已投递(async)', 'data': {'Async': True}})
+ result = task.apply()
+ detail = result.result if result is not None else None
+ return Response({'code': 0, 'msg': f'任务 {name} 执行完成', 'data': {'Async': False, 'Result': detail}})
+ except Exception:
+ logger.exception('GERPSystemScheduleTriggerView 触发任务失败 name=%s', name)
+ return Response({'code': 1, 'msg': '任务执行异常,请查看服务器日志', 'data': None}, status=500)
+
+
+class GERPSystemSecretsView(ELTAuthenticatedAPIView):
+ """secrets.ini 配置管理(脱敏读取 / 键值编辑)
+
+ GET /API/GERPSystem/Secrets — 返回全部 section 与键,敏感键值脱敏
+ POST /API/GERPSystem/Secrets — Body: {Sections: {section: {key: value}}}
+ value 为 '******' 的键视为未修改,跳过写入
+ """
+
+ def get(self, request):
+ if not _IsSuperAdmin(request):
+ return _Denied()
+
+ structure = _ReadIniStructure(_SECRETS_FILE)
+ sections = {}
+ for section, kv in structure.items():
+ sections[section] = {
+ key: {
+ 'Value': _MASK if _IsSensitiveKey(key) else value,
+ 'Masked': _IsSensitiveKey(key),
+ }
+ for key, value in kv.items()
+ }
+ return Response({
+ 'code': 0,
+ 'data': {
+ 'FilePath': str(_SECRETS_FILE),
+ 'Sections': sections,
+ },
+ })
+
+ def post(self, request):
+ if not _IsSuperAdmin(request):
+ return _Denied()
+
+ updates = request.data.get('Sections') or {}
+ if not isinstance(updates, dict) or not updates:
+ return Response({'code': 1, 'msg': '缺少 Sections 参数', 'data': None}, status=400)
+
+ updated_keys, err = _UpdateIniFile(_SECRETS_FILE, updates)
+ if err:
+ return Response({'code': 1, 'msg': err, 'data': None}, status=400)
+
+ # 热重载 secrets 配置缓存,使新增/修改立即可被后续读取
+ try:
+ from gvsds.secrets import reload as _reload_secrets
+ _reload_secrets()
+ except Exception:
+ logger.exception('secrets.ini 重载失败')
+
+ return Response({'code': 0, 'msg': '配置已保存', 'data': {'Updated': updated_keys}})
+
+
+class GERPSystemRuntimeView(ELTAuthenticatedAPIView):
+ """运行时调整——日志级别热切换(持久化到 app.ini [logging] LEVEL)
+
+ GET /API/GERPSystem/Runtime — 当前日志级别
+ POST /API/GERPSystem/Runtime — Body: {LogLevel: DEBUG|INFO|WARNING|ERROR}
+ """
+
+ _LEVELS = ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
+
+ def get(self, request):
+ if not _IsSuperAdmin(request):
+ return _Denied()
+ current = logging.getLogger('gvsds').getEffectiveLevel()
+ # app.ini 持久化值(未配置时取运行值)
+ persisted = None
+ try:
+ from gvsds import app_config
+ persisted = app_config.get('logging', 'LEVEL', fallback=None)
+ except Exception:
+ pass
+ return Response({
+ 'code': 0,
+ 'data': {
+ 'Current': logging.getLevelName(current),
+ 'Persisted': persisted,
+ 'Available': list(self._LEVELS),
+ },
+ })
+
+ def post(self, request):
+ if not _IsSuperAdmin(request):
+ return _Denied()
+
+ level = (request.data.get('LogLevel') or '').strip().upper()
+ if level not in self._LEVELS:
+ return Response({'code': 1, 'msg': f'日志级别无效,可选:{" / ".join(self._LEVELS)}', 'data': None}, status=400)
+
+ # 热切换:gvsds 命名空间下全部 logger 生效(root 级联)
+ logging.getLogger('gvsds').setLevel(level)
+ logging.getLogger().setLevel(level)
+
+ # 持久化到 app.ini [logging] LEVEL + 热重载
+ try:
+ from gvsds import app_config
+ updated, err = _UpdateIniFile(_APP_INI_FILE, {'logging': {'LEVEL': level}})
+ if err:
+ return Response({'code': 1, 'msg': f'运行时已切换,但持久化失败:{err}', 'data': None}, status=500)
+ app_config.reload()
+ except Exception:
+ logger.exception('日志级别持久化失败')
+
+ return Response({'code': 0, 'msg': f'日志级别已切换为 {level}', 'data': {'LogLevel': level}})
diff --git a/gvsds/urls.py b/gvsds/urls.py
index 27b7024b..0c332e11 100644
--- a/gvsds/urls.py
+++ b/gvsds/urls.py
@@ -194,6 +194,13 @@ urlpatterns = [
path('API/Gitea/AdminList', gerp_views.GiteaAdminListView.as_view(), name='gitea_admin_list'),
path('API/Gitea/Disable', gerp_views.GiteaDisableView.as_view(), name='gitea_disable'),
+ # GERP系统 运维管理(仅系统管理员 admin:super)
+ path('API/GERPSystem/Status', gerp_views.GERPSystemStatusView.as_view(), name='gerp_system_status'),
+ path('API/GERPSystem/Schedules', gerp_views.GERPSystemSchedulesView.as_view(), name='gerp_system_schedules'),
+ path('API/GERPSystem/Schedules/Trigger', gerp_views.GERPSystemScheduleTriggerView.as_view(), name='gerp_system_trigger'),
+ path('API/GERPSystem/Secrets', gerp_views.GERPSystemSecretsView.as_view(), name='gerp_system_secrets'),
+ path('API/GERPSystem/Runtime', gerp_views.GERPSystemRuntimeView.as_view(), name='gerp_system_runtime'),
+
# Gitea 免密登录(OIDC Provider)
path('.well-known/openid-configuration', gerp_oidc.OIDCDiscoveryView.as_view(), name='oidc_discovery'),
path('oidc/authorize', gerp_oidc.OIDCAuthorizeView.as_view(), name='oidc_authorize'),