feat(backend): RBAC admin roles + runtime logs service
This commit is contained in:
@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.config_change_log import ConfigChangeLog
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.models.quick_reply_template import QuickReplyTemplate
|
||||
@@ -451,7 +452,10 @@ async def update_config(
|
||||
|
||||
old_value = config.config_value
|
||||
|
||||
# 写入变更日志
|
||||
# ===== A+B 双写(决策2:配置变更同时进入 config_change_logs 与 audit_logs)=====
|
||||
# 表 A:config_change_logs(既有,供"配置变更历史"页)
|
||||
# 表 B:audit_logs 的 config_change 事件(供"安全审计日志"页,复用 record_audit_log 统一构造)
|
||||
# 两表在同一 DB session 内 add,由调用方统一 commit(与 record_audit_log 约定一致:本函数不 commit)。
|
||||
change_log = ConfigChangeLog(
|
||||
config_key=key,
|
||||
old_value=old_value,
|
||||
@@ -460,6 +464,26 @@ async def update_config(
|
||||
)
|
||||
db.add(change_log)
|
||||
|
||||
# 表 B:audit_logs —— 严格复用 app.services.audit_log_service.record_audit_log 的构造方式
|
||||
# (字段命名 / 默认值保持一致)。update_config 为服务层、无 FastAPI Request 上下文,
|
||||
# 故 ip_address / user_agent 置 None(record_audit_log 在无 request 时同样取 None)。
|
||||
audit_log = AuditLog(
|
||||
employee_id=agent_id,
|
||||
action="config_change",
|
||||
resource="system_config",
|
||||
resource_id=key,
|
||||
details={
|
||||
"old": old_value,
|
||||
"new": value,
|
||||
"operator": agent_id,
|
||||
},
|
||||
result="success",
|
||||
ip_address=None,
|
||||
user_agent=None,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
db.add(audit_log)
|
||||
|
||||
# 更新配置值
|
||||
config.config_value = value
|
||||
config.updated_at = datetime.now()
|
||||
@@ -1690,19 +1714,53 @@ async def get_system_logs(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
config_key: Optional[str] = None,
|
||||
changed_by: Optional[str] = None,
|
||||
from_time: Optional[datetime] = None,
|
||||
to_time: Optional[datetime] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取系统日志(配置变更日志)。"""
|
||||
count_result = await db.execute(select(func.count(ConfigChangeLog.id)))
|
||||
total = count_result.scalar() or 0
|
||||
"""获取系统日志(配置变更日志),支持配置键/操作人/时间范围筛选。
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(ConfigChangeLog)
|
||||
Args:
|
||||
db: 数据库会话
|
||||
page: 页码,从 1 开始
|
||||
page_size: 每页条数
|
||||
config_key: 按配置键精确筛选(可选,空则不追加条件)
|
||||
changed_by: 按操作人 agent_id 精确筛选(可选)
|
||||
from_time: 变更时间起始(闭区间,可选)
|
||||
to_time: 变更时间截止(闭区间,可选)
|
||||
|
||||
Returns:
|
||||
Dict: {items, total, page, page_size}
|
||||
"""
|
||||
# 动态构建筛选条件(空参数不追加条件,保持旧行为)
|
||||
conditions = []
|
||||
if config_key:
|
||||
conditions.append(ConfigChangeLog.config_key == config_key)
|
||||
if changed_by:
|
||||
conditions.append(ConfigChangeLog.changed_by == changed_by)
|
||||
if from_time:
|
||||
conditions.append(ConfigChangeLog.changed_at >= from_time)
|
||||
if to_time:
|
||||
conditions.append(ConfigChangeLog.changed_at <= to_time)
|
||||
|
||||
# 总数(带筛选)
|
||||
count_stmt = select(func.count(ConfigChangeLog.id))
|
||||
if conditions:
|
||||
count_stmt = count_stmt.where(and_(*conditions))
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
# 分页查询(带筛选,按变更时间倒序)
|
||||
query = select(ConfigChangeLog)
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
query = (
|
||||
query
|
||||
.order_by(ConfigChangeLog.changed_at.desc())
|
||||
.offset(offset)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
logs = list(result.scalars().all())
|
||||
logs = list((await db.execute(query)).scalars().all())
|
||||
|
||||
agent_ids = list({log.changed_by for log in logs if log.changed_by})
|
||||
agent_names: Dict[str, str] = {}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# =============================================================================
|
||||
# 员工目录解析服务
|
||||
# =============================================================================
|
||||
# 说明:角色分配时,将管理员输入的「员工账号 或 姓名」解析为企微 UserID,
|
||||
# 并校验该员工确实属于企微组织架构(需求:分配角色时按姓名/账号自动转换 + 校验)。
|
||||
#
|
||||
# 数据源优先级(自动适配,无需改代码即可在权限开通后升级):
|
||||
# 1. 企微通讯录(实时):
|
||||
# - get_user_info(userid) 校验账号是否为组织内真实员工
|
||||
# - get_department_members(1, 1) 拉取全组织架构,用于「姓名 -> 账号」匹配
|
||||
# - 需要企微应用具备「通讯录读取」权限;权限不足(errcode 60011)时自动降级
|
||||
# 2. 本地 employees 表(仅登录过的员工):作为降级目录,保证功能在缺权限时仍可用
|
||||
#
|
||||
# 设计目标:无论企微权限是否齐全,分配功能都可用;权限齐全时自动获得全公司
|
||||
# 姓名搜索能力(full_directory=True),缺权限时仅覆盖已登录员工。
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.employee import Employee
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 组织目录 Redis 缓存 key 与 TTL(10 分钟,避免频繁调用企微通讯录 API)
|
||||
ORG_DIRECTORY_CACHE_KEY = "wecom:org_directory"
|
||||
ORG_DIRECTORY_CACHE_TTL = 600
|
||||
|
||||
|
||||
async def get_org_directory(
|
||||
db: AsyncSession,
|
||||
redis: Optional[aioredis.Redis],
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""获取「组织目录」(员工账号 + 姓名 列表),用于姓名 -> 账号匹配。
|
||||
|
||||
优先返回缓存;缓存未命中时尝试从企微通讯录拉全组织(需通讯录读取权限)。
|
||||
若企微权限不足或调用失败,降级到本地 employees 表。
|
||||
|
||||
Returns:
|
||||
(directory, full_directory)
|
||||
- directory: [{"employee_id": str, "name": str, "department": str}, ...]
|
||||
- full_directory: True=来自企微全组织(覆盖全公司);False=仅本地已登录员工
|
||||
"""
|
||||
# 1. 尝试命中缓存(缓存一定来自企微全组织,full=True)
|
||||
if redis:
|
||||
try:
|
||||
raw = await redis.get(ORG_DIRECTORY_CACHE_KEY)
|
||||
if raw:
|
||||
logger.debug("命中组织目录缓存")
|
||||
return json.loads(raw.decode("utf-8")), True
|
||||
except Exception as e:
|
||||
logger.warning(f"读取组织目录缓存失败(降级): {e}")
|
||||
|
||||
# 2. 尝试从企微通讯录拉全组织
|
||||
wecom = WecomService(redis_client=redis)
|
||||
try:
|
||||
members = await wecom.get_department_members(1, 1)
|
||||
directory = [
|
||||
{
|
||||
"employee_id": m.get("userid", ""),
|
||||
"name": m.get("name", "") or "",
|
||||
"department": ",".join(str(d) for d in (m.get("department") or [])),
|
||||
}
|
||||
for m in members
|
||||
if m.get("userid")
|
||||
]
|
||||
# 写入缓存(仅全组织结果缓存,降级结果不缓存以免长期误用)
|
||||
if redis:
|
||||
try:
|
||||
await redis.setex(
|
||||
ORG_DIRECTORY_CACHE_KEY,
|
||||
ORG_DIRECTORY_CACHE_TTL,
|
||||
json.dumps(directory, ensure_ascii=False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"写入组织目录缓存失败: {e}")
|
||||
logger.info(f"组织目录来自企微全组织,共 {len(directory)} 人")
|
||||
return directory, True
|
||||
except Exception as e:
|
||||
err_text = str(e)
|
||||
if "60011" in err_text or "privilege" in err_text.lower():
|
||||
logger.warning("企微通讯录部门读取权限不足,降级到本地 employees 表")
|
||||
else:
|
||||
logger.warning(f"企微通讯录获取失败,降级本地: {err_text}")
|
||||
|
||||
# 3. 降级:本地 employees 表(仅登录过的员工)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Employee.employee_id, Employee.name).where(Employee.employee_id != "")
|
||||
)
|
||||
rows = result.all()
|
||||
directory = [
|
||||
{"employee_id": r[0], "name": r[1] or "", "department": ""} for r in rows
|
||||
]
|
||||
logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)} 人")
|
||||
return directory, False
|
||||
except Exception as e:
|
||||
logger.error(f"本地 employees 表查询失败: {e}")
|
||||
return [], False
|
||||
|
||||
|
||||
async def resolve_target(
|
||||
target: str,
|
||||
db: AsyncSession,
|
||||
redis: Optional[aioredis.Redis] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""将输入的「员工账号 或 姓名」解析为企微 UserID,并校验组织内存在性。
|
||||
|
||||
Returns(结构化结果,由调用方翻译为响应/异常):
|
||||
{"found": True, "employee_id": str, "name": str, "source": str}
|
||||
{"found": False, "reason": str, "suggestion": str}
|
||||
{"ambiguous": True, "candidates": [{"employee_id","name","department"}, ...]}
|
||||
"""
|
||||
target = (target or "").strip()
|
||||
if not target:
|
||||
return {
|
||||
"found": False,
|
||||
"reason": "请输入员工账号或姓名",
|
||||
"suggestion": "请填写企微员工账号或姓名后重试",
|
||||
}
|
||||
|
||||
wecom = WecomService(redis_client=redis)
|
||||
|
||||
# 1) 先尝试按 userid 实时校验(企微组织内真实员工)
|
||||
try:
|
||||
info = await wecom.get_user_info(target)
|
||||
# 成功 -> target 本身就是有效 userid
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": info.get("userid") or target,
|
||||
"name": info.get("name") or "",
|
||||
"source": "wecom_userid",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"get_user_info('{target}') 未命中(将尝试按姓名解析): {e}")
|
||||
|
||||
# 2) 按姓名(包含)解析
|
||||
directory, full = await get_org_directory(db, redis)
|
||||
ql = target.lower()
|
||||
# 精确 userid 匹配优先(目录里可能存在)
|
||||
exact = [m for m in directory if m["employee_id"] and m["employee_id"].lower() == ql]
|
||||
# 姓名包含匹配
|
||||
name_hits = [m for m in directory if m["name"] and ql in m["name"].lower()]
|
||||
matches = exact if exact else name_hits
|
||||
|
||||
if len(matches) == 1:
|
||||
m = matches[0]
|
||||
# 二次实时校验该 userid 确实在组织内(网络可用时)
|
||||
try:
|
||||
info = await wecom.get_user_info(m["employee_id"])
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": info.get("userid") or m["employee_id"],
|
||||
"name": info.get("name") or m.get("name", ""),
|
||||
"source": "wecom_name" if full else "local_name",
|
||||
}
|
||||
except Exception:
|
||||
# 实时校验失败(网络/权限),但目录里有 -> 仍可用
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": m["employee_id"],
|
||||
"name": m.get("name", ""),
|
||||
"source": "local_name",
|
||||
}
|
||||
|
||||
if len(matches) > 1:
|
||||
return {
|
||||
"ambiguous": True,
|
||||
"candidates": [
|
||||
{
|
||||
"employee_id": m["employee_id"],
|
||||
"name": m["name"],
|
||||
"department": m.get("department", ""),
|
||||
}
|
||||
for m in matches[:10]
|
||||
],
|
||||
}
|
||||
|
||||
# 未找到
|
||||
if full:
|
||||
return {
|
||||
"found": False,
|
||||
"reason": f"企微组织架构中未找到匹配「{target}」的员工",
|
||||
"suggestion": "请确认姓名/账号拼写,或改为输入员工账号",
|
||||
}
|
||||
return {
|
||||
"found": False,
|
||||
"reason": f"未找到匹配「{target}」的员工",
|
||||
"suggestion": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司",
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 运行期日志服务
|
||||
# =============================================================================
|
||||
# 说明:读取后端运行期日志目录(config.RUNTIME_LOG_DIR)下的 *.log 文件,
|
||||
# 逐行 json.loads(容错跳过非 JSON 行),按 级别 >= 阈值 / 时间区间 /
|
||||
# 关键字 过滤,按时间倒序分页返回结构化条目;
|
||||
# download 模式返回原始 JSON 行(供流式下载)。
|
||||
# 目录不存在 / 不可读时返回空结果(不抛 500)。
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 日志级别数值映射(用于 >= 阈值 比较)
|
||||
_LEVEL_ORDER = {
|
||||
"DEBUG": 10,
|
||||
"INFO": 20,
|
||||
"WARNING": 30,
|
||||
"ERROR": 40,
|
||||
"CRITICAL": 50,
|
||||
}
|
||||
|
||||
# 轮转备份命名形如 wecom-it-desk.log.1 / .2 ...
|
||||
_ROTATED_RE = re.compile(r"\.log\.\d+$")
|
||||
|
||||
|
||||
def _level_num(level: str) -> int:
|
||||
"""将级别名转为数值(用于 >= 阈值比较)。"""
|
||||
return _LEVEL_ORDER.get((level or "INFO").upper(), 20)
|
||||
|
||||
|
||||
def _parse_ts(ts: Optional[str]) -> datetime:
|
||||
"""解析 JSONFormatter 写入的时间戳(ISO8601,可能带 Z)。
|
||||
|
||||
解析失败时返回 datetime.min,使其排在最后(不影响过滤语义)。
|
||||
"""
|
||||
if not ts:
|
||||
return datetime.min
|
||||
try:
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%f")
|
||||
except (ValueError, TypeError):
|
||||
return datetime.min
|
||||
|
||||
|
||||
def _list_log_files() -> List[str]:
|
||||
"""列出运行期日志目录下所有 .log 文件(含轮转备份)。
|
||||
|
||||
目录不存在 / 不可读时返回空列表(不抛异常)。
|
||||
"""
|
||||
log_dir = getattr(settings, "RUNTIME_LOG_DIR", None) or "/app/logs"
|
||||
if not log_dir or not os.path.isdir(log_dir):
|
||||
return []
|
||||
files: List[str] = []
|
||||
try:
|
||||
for name in os.listdir(log_dir):
|
||||
if name.endswith(".log") or _ROTATED_RE.search(name):
|
||||
full = os.path.join(log_dir, name)
|
||||
if os.path.isfile(full):
|
||||
files.append(full)
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"读取运行期日志目录失败: {log_dir}: {e}")
|
||||
return []
|
||||
# 按文件名排序,保证读取顺序稳定(主日志优先于轮转备份)
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
|
||||
def _iter_raw_lines() -> List[str]:
|
||||
"""逐文件读取所有日志行(原始文本,已去除行尾换行)。"""
|
||||
lines: List[str] = []
|
||||
for path in _list_log_files():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
stripped = line.rstrip("\n").rstrip("\r")
|
||||
if stripped:
|
||||
lines.append(stripped)
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"读取运行期日志文件失败: {path}: {e}")
|
||||
continue
|
||||
return lines
|
||||
|
||||
|
||||
def _match(
|
||||
entry: Dict[str, Any],
|
||||
level_th: int,
|
||||
from_time: Optional[datetime],
|
||||
to_time: Optional[datetime],
|
||||
keyword: Optional[str],
|
||||
) -> bool:
|
||||
"""判断单条日志是否命中筛选条件。"""
|
||||
# 级别阈值:仅保留 >= 阈值的条目
|
||||
if _level_num(entry.get("level", "INFO")) < level_th:
|
||||
return False
|
||||
# 时间区间(闭区间)
|
||||
ts = _parse_ts(entry.get("timestamp"))
|
||||
if from_time and ts < from_time:
|
||||
return False
|
||||
if to_time and ts > to_time:
|
||||
return False
|
||||
# 关键字:消息子串(大小写不敏感);为空则跳过该过滤
|
||||
if keyword:
|
||||
msg = str(entry.get("message", ""))
|
||||
if keyword.lower() not in msg.lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def query_runtime_logs(
|
||||
level: str = "INFO",
|
||||
from_time: Optional[datetime] = None,
|
||||
to_time: Optional[datetime] = None,
|
||||
keyword: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Dict[str, Any]:
|
||||
"""查询运行期日志(分页 + 多条件筛选)。
|
||||
|
||||
读取 config.RUNTIME_LOG_DIR 下 *.log(含轮转备份),逐行 json.loads
|
||||
(容错跳过非 JSON 行),按 级别>=阈值 / 时间区间 / 关键字 过滤,
|
||||
按时间倒序分页返回结构化条目。
|
||||
|
||||
目录不存在 / 不可读时返回空列表(不抛 500)。
|
||||
|
||||
Args:
|
||||
level: 级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别
|
||||
from_time: 起始时间(可选)
|
||||
to_time: 结束时间(可选)
|
||||
keyword: 消息关键字子串(可选,大小写不敏感)
|
||||
page: 页码,从 1 开始
|
||||
page_size: 每页条数
|
||||
|
||||
Returns:
|
||||
Dict: {items, total, page, page_size}
|
||||
"""
|
||||
level_th = _level_num(level)
|
||||
entries: List[Dict[str, Any]] = []
|
||||
for raw in _iter_raw_lines():
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# 容错:跳过非 JSON 行
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if _match(entry, level_th, from_time, to_time, keyword):
|
||||
entries.append(entry)
|
||||
|
||||
# 按时间倒序(新 -> 旧)
|
||||
entries.sort(key=lambda e: _parse_ts(e.get("timestamp")), reverse=True)
|
||||
|
||||
total = len(entries)
|
||||
page = max(1, page)
|
||||
page_size = max(1, page_size)
|
||||
start = (page - 1) * page_size
|
||||
page_items = entries[start:start + page_size]
|
||||
|
||||
items = []
|
||||
for e in page_items:
|
||||
items.append({
|
||||
"timestamp": e.get("timestamp", ""),
|
||||
"level": e.get("level", ""),
|
||||
"logger": e.get("logger", ""),
|
||||
"message": e.get("message", ""),
|
||||
"module": e.get("module", ""),
|
||||
"function": e.get("function", ""),
|
||||
"line": e.get("line", ""),
|
||||
"request_id": e.get("request_id"),
|
||||
"user_id": e.get("user_id"),
|
||||
})
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def iter_runtime_log_lines(
|
||||
level: str = "INFO",
|
||||
from_time: Optional[datetime] = None,
|
||||
to_time: Optional[datetime] = None,
|
||||
keyword: Optional[str] = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""流式返回命中的原始 JSON 行(供下载)。
|
||||
|
||||
逐行读取并过滤,命中即 yield 原始行文本(带换行),
|
||||
便于 StreamingResponse 直接流式输出为 .log 文件。
|
||||
|
||||
Args:
|
||||
level: 级别阈值
|
||||
from_time: 起始时间(可选)
|
||||
to_time: 结束时间(可选)
|
||||
keyword: 消息关键字子串(可选)
|
||||
|
||||
Yields:
|
||||
str: 命中的原始 JSON 日志行(含行尾换行)
|
||||
"""
|
||||
level_th = _level_num(level)
|
||||
for raw in _iter_raw_lines():
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if _match(entry, level_th, from_time, to_time, keyword):
|
||||
yield raw + "\n"
|
||||
Reference in New Issue
Block a user