feat(auth): add opaque bearer sessions
This commit is contained in:
@@ -6,10 +6,8 @@ from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_session_factory
|
||||
|
||||
|
||||
PLATFORM_ADMIN_IDENTITIES = {"admin", "superadmin"}
|
||||
ADMIN_HEADER_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
from app.services.auth import AuthService
|
||||
from app.services.auth_sessions import AuthSessionService
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
@@ -33,82 +31,72 @@ class CurrentUserContext:
|
||||
grade: str = ""
|
||||
employee_no: str = ""
|
||||
manager_name: str = ""
|
||||
employee_id: str = ""
|
||||
auth_session_id: str = ""
|
||||
|
||||
|
||||
def get_current_user(
|
||||
x_auth_username: Annotated[
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
authorization: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录用户名。知识库接口至少需要提供用户名或姓名。"),
|
||||
] = None,
|
||||
x_auth_name: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人展示姓名。未传时默认回退到用户名。"),
|
||||
] = None,
|
||||
x_auth_role_codes: Annotated[
|
||||
str | None,
|
||||
Header(description="角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。"),
|
||||
] = None,
|
||||
x_auth_is_admin: Annotated[
|
||||
str | None,
|
||||
Header(description="是否管理员,支持 `true/false/1/0`。"),
|
||||
] = None,
|
||||
x_auth_department: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的所属部门。"),
|
||||
] = None,
|
||||
x_auth_cost_center: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的成本中心。"),
|
||||
] = None,
|
||||
x_auth_position: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的岗位。"),
|
||||
] = None,
|
||||
x_auth_grade: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的职级。"),
|
||||
] = None,
|
||||
x_auth_employee_no: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的员工编号。"),
|
||||
] = None,
|
||||
x_auth_manager_name: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的直属领导。"),
|
||||
Header(description="登录接口签发的 `Authorization: Bearer <token>`。"),
|
||||
] = None,
|
||||
) -> CurrentUserContext:
|
||||
role_codes = [
|
||||
_normalize_role_code(item)
|
||||
for item in (x_auth_role_codes or "").split(",")
|
||||
if _normalize_role_code(item)
|
||||
]
|
||||
username = (x_auth_username or "").strip()
|
||||
name = (x_auth_name or username).strip()
|
||||
is_admin = _resolve_platform_admin_flag(
|
||||
username=username,
|
||||
name=name,
|
||||
role_codes=role_codes,
|
||||
header_value=x_auth_is_admin,
|
||||
)
|
||||
return _authenticate_bearer_user(db, authorization)
|
||||
|
||||
if not username and not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请先登录后再访问知识库。",
|
||||
)
|
||||
|
||||
def get_optional_current_user(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
authorization: Annotated[
|
||||
str | None,
|
||||
Header(description="系统已初始化时用于管理员校验的 Bearer Token。"),
|
||||
] = None,
|
||||
) -> CurrentUserContext | None:
|
||||
if not str(authorization or "").strip():
|
||||
return None
|
||||
return _authenticate_bearer_user(db, authorization)
|
||||
|
||||
|
||||
def _authenticate_bearer_user(db: Session, authorization: str | None) -> CurrentUserContext:
|
||||
access_token = _extract_bearer_token(authorization)
|
||||
auth_session = AuthSessionService(db).authenticate(access_token)
|
||||
if auth_session is None:
|
||||
raise _unauthorized("登录会话不存在、已过期或已退出,请重新登录。")
|
||||
|
||||
user = AuthService(db).get_session_user(auth_session)
|
||||
if user is None:
|
||||
raise _unauthorized("当前登录用户不存在、已停用或权限已失效。")
|
||||
|
||||
return CurrentUserContext(
|
||||
username=username or name,
|
||||
name=name or username,
|
||||
role_codes=role_codes,
|
||||
is_admin=is_admin,
|
||||
tenant_id="default",
|
||||
department_name=(x_auth_department or "").strip(),
|
||||
cost_center=(x_auth_cost_center or "").strip(),
|
||||
position=(x_auth_position or "").strip(),
|
||||
grade=(x_auth_grade or "").strip(),
|
||||
employee_no=(x_auth_employee_no or "").strip(),
|
||||
manager_name=(x_auth_manager_name or "").strip(),
|
||||
username=user.username,
|
||||
name=user.name,
|
||||
role_codes=[_normalize_role_code(item) for item in user.role_codes],
|
||||
is_admin=user.is_admin,
|
||||
tenant_id=user.tenant_id,
|
||||
department_name=user.department,
|
||||
cost_center=user.cost_center,
|
||||
position=user.position,
|
||||
grade=user.grade,
|
||||
employee_no=user.employee_no,
|
||||
manager_name=user.manager_name,
|
||||
employee_id=user.employee_id or "",
|
||||
auth_session_id=auth_session.id,
|
||||
)
|
||||
|
||||
|
||||
def _extract_bearer_token(authorization: str | None) -> str:
|
||||
normalized = str(authorization or "").strip()
|
||||
scheme, separator, token = normalized.partition(" ")
|
||||
if not separator or scheme.casefold() != "bearer" or not token.strip():
|
||||
raise _unauthorized("请先登录后再访问。")
|
||||
return token.strip()
|
||||
|
||||
|
||||
def _unauthorized(detail: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@@ -120,25 +108,11 @@ def _normalize_role_code(value: str | None) -> str:
|
||||
|
||||
|
||||
def _current_user_role_codes(current_user: CurrentUserContext) -> set[str]:
|
||||
return {_normalize_role_code(item) for item in current_user.role_codes if _normalize_role_code(item)}
|
||||
|
||||
|
||||
def _resolve_platform_admin_flag(
|
||||
*,
|
||||
username: str,
|
||||
name: str,
|
||||
role_codes: list[str],
|
||||
header_value: str | None,
|
||||
) -> bool:
|
||||
if str(header_value or "").strip().lower() in ADMIN_HEADER_TRUE_VALUES:
|
||||
return True
|
||||
|
||||
identities = {
|
||||
str(username or "").strip().lower(),
|
||||
str(name or "").strip().lower(),
|
||||
return {
|
||||
_normalize_role_code(item)
|
||||
for item in current_user.role_codes
|
||||
if _normalize_role_code(item)
|
||||
}
|
||||
normalized_role_codes = {_normalize_role_code(item) for item in role_codes}
|
||||
return bool(identities & PLATFORM_ADMIN_IDENTITIES) or bool(normalized_role_codes & PLATFORM_ADMIN_IDENTITIES)
|
||||
|
||||
|
||||
def require_admin_user(
|
||||
|
||||
@@ -5,12 +5,12 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.schemas.agent_run import AgentRunRead, AgentRunStatsRead
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.agent_runs import AgentRunService
|
||||
|
||||
router = APIRouter(prefix="/agent-runs")
|
||||
router = APIRouter(prefix="/agent-runs", dependencies=[Depends(get_current_user)])
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_admin_user
|
||||
from app.api.deps import get_db, require_platform_admin_user
|
||||
from app.schemas.agent_trace import (
|
||||
AgentConversationTraceRead,
|
||||
AgentTraceDetailRead,
|
||||
@@ -26,7 +26,7 @@ DbSession = Annotated[Session, Depends(get_db)]
|
||||
)
|
||||
def list_agent_traces(
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
_: Annotated[object, Depends(require_platform_admin_user)],
|
||||
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
|
||||
status_value: Annotated[
|
||||
str | None,
|
||||
@@ -56,7 +56,7 @@ def list_agent_traces(
|
||||
def get_conversation_trace(
|
||||
conversation_id: str,
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
_: Annotated[object, Depends(require_platform_admin_user)],
|
||||
) -> AgentConversationTraceRead:
|
||||
return AgentTraceService(db).get_conversation_trace(conversation_id)
|
||||
|
||||
@@ -76,7 +76,7 @@ def get_conversation_trace(
|
||||
def get_agent_trace(
|
||||
run_id: str,
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
_: Annotated[object, Depends(require_platform_admin_user)],
|
||||
) -> AgentTraceDetailRead:
|
||||
trace = AgentTraceService(db).get_trace(run_id)
|
||||
if trace is None:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
|
||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||
from app.schemas.system_dashboard import SystemDashboardRead
|
||||
@@ -14,7 +14,7 @@ from app.services.digital_employee_dashboard import DigitalEmployeeDashboardServ
|
||||
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
|
||||
from app.services.system_dashboard import SystemDashboardService
|
||||
|
||||
router = APIRouter(prefix="/analytics")
|
||||
router = APIRouter(prefix="/analytics", dependencies=[Depends(get_current_user)])
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import get_db, require_platform_admin_user
|
||||
from app.schemas.audit_log import AuditLogRead
|
||||
from app.services.audit import AuditLogService
|
||||
|
||||
router = APIRouter(prefix="/audit-logs")
|
||||
router = APIRouter(prefix="/audit-logs", dependencies=[Depends(require_platform_admin_user)])
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ from app.schemas.auth import (
|
||||
AuthUserRead,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogoutRequest,
|
||||
LogoutResponse,
|
||||
SessionFinishRequest,
|
||||
SessionFinishResponse,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.auth import AuthService
|
||||
from app.services.auth_sessions import AuthSessionService
|
||||
from app.services.user_session_metrics import UserSessionMetricService
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
@@ -44,7 +47,7 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
|
||||
"/me",
|
||||
response_model=AuthUserRead,
|
||||
summary="读取当前登录用户",
|
||||
description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。",
|
||||
description="根据 Bearer 会话刷新前端登录态中的员工姓名、部门、岗位和职级。",
|
||||
)
|
||||
def get_current_auth_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
@@ -68,12 +71,49 @@ def get_current_auth_user(
|
||||
managerName=current_user.manager_name,
|
||||
costCenter=current_user.cost_center,
|
||||
roleCodes=current_user.role_codes or ["manager"],
|
||||
email=current_user.username if "@" in current_user.username else f"{current_user.username}@local",
|
||||
email=(
|
||||
current_user.username
|
||||
if "@" in current_user.username
|
||||
else f"{current_user.username}@local"
|
||||
),
|
||||
avatar=name[:1].upper(),
|
||||
isAdmin=True,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="当前登录用户不存在或已停用")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="当前登录用户不存在或已停用",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
response_model=LogoutResponse,
|
||||
summary="退出当前登录会话",
|
||||
description="撤销当前 Bearer Token;撤销后该凭证不可再次使用。",
|
||||
)
|
||||
def logout(
|
||||
payload: LogoutRequest,
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
db: DbSession,
|
||||
) -> LogoutResponse:
|
||||
try:
|
||||
if payload.sessionId:
|
||||
UserSessionMetricService(db).finish_session(
|
||||
session_id=payload.sessionId,
|
||||
expected_username=current_user.username,
|
||||
reason=payload.reason,
|
||||
last_activity_at=payload.lastActivityAt,
|
||||
activity_event_count=payload.activityEventCount,
|
||||
event={"page_path": payload.pagePath},
|
||||
commit=False,
|
||||
)
|
||||
AuthSessionService(db).revoke(current_user.auth_session_id, commit=False)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
return LogoutResponse()
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -84,10 +124,12 @@ def get_current_auth_user(
|
||||
def finish_session(
|
||||
session_id: str,
|
||||
payload: SessionFinishRequest,
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
db: DbSession,
|
||||
) -> SessionFinishResponse:
|
||||
session = UserSessionMetricService(db).finish_session(
|
||||
session_id=session_id,
|
||||
expected_username=current_user.username,
|
||||
reason=payload.reason,
|
||||
last_activity_at=payload.lastActivityAt,
|
||||
activity_event_count=payload.activityEventCount,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_optional_current_user
|
||||
from app.core.bootstrap import build_bootstrap_state, persist_bootstrap_config
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
|
||||
@@ -16,7 +19,11 @@ router = APIRouter(prefix="/bootstrap")
|
||||
description="返回当前系统是否已完成初始化,以及公司、数据库和缓存配置快照。",
|
||||
)
|
||||
def get_bootstrap_state() -> BootstrapStateRead:
|
||||
return build_bootstrap_state(get_settings())
|
||||
settings = get_settings()
|
||||
return build_bootstrap_state(
|
||||
settings,
|
||||
redact_infrastructure=settings.setup_completed,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -26,5 +33,17 @@ def get_bootstrap_state() -> BootstrapStateRead:
|
||||
summary="写入初始化配置",
|
||||
description="保存系统初始化配置,并刷新运行时数据库连接。",
|
||||
)
|
||||
def initialize_bootstrap(payload: BootstrapSetupPayload) -> BootstrapStateRead:
|
||||
return persist_bootstrap_config(payload, get_settings())
|
||||
def initialize_bootstrap(
|
||||
payload: BootstrapSetupPayload,
|
||||
current_user: Annotated[
|
||||
CurrentUserContext | None,
|
||||
Depends(get_optional_current_user),
|
||||
],
|
||||
) -> BootstrapStateRead:
|
||||
settings = get_settings()
|
||||
if settings.setup_completed and (current_user is None or not current_user.is_admin):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="系统已完成初始化,只有平台管理员可以重新写入初始化配置。",
|
||||
)
|
||||
return persist_bootstrap_config(payload, settings)
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile,
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import get_db, require_admin_user
|
||||
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
||||
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||
from app.schemas.employee import (
|
||||
@@ -19,7 +19,7 @@ from app.schemas.employee import (
|
||||
from app.services.employee import EmployeeService
|
||||
from app.services.employee_pagination import EmployeePaginationService
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(dependencies=[Depends(require_admin_user)])
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.risk_observation import (
|
||||
RiskObservationDashboardRead,
|
||||
@@ -16,7 +16,7 @@ from app.schemas.risk_observation import (
|
||||
)
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
router = APIRouter(prefix="/risk-observations")
|
||||
router = APIRouter(prefix="/risk-observations", dependencies=[Depends(get_current_user)])
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_db, require_admin_user
|
||||
from app.api.deps import (
|
||||
CurrentUserContext,
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_platform_admin_user,
|
||||
)
|
||||
from app.core.config import get_settings as get_runtime_settings
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.settings import (
|
||||
@@ -52,7 +57,10 @@ def require_hermes_agent_token(
|
||||
summary="读取系统设置",
|
||||
description="返回公司、管理员、模型、日志、邮件和 ONLYOFFICE 的设置快照。",
|
||||
)
|
||||
def get_settings(db: DbSession) -> SettingsRead:
|
||||
def get_settings(
|
||||
db: DbSession,
|
||||
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> SettingsRead:
|
||||
return SettingsService(db).get_settings_snapshot()
|
||||
|
||||
|
||||
@@ -68,7 +76,11 @@ def get_settings(db: DbSession) -> SettingsRead:
|
||||
}
|
||||
},
|
||||
)
|
||||
def update_settings(payload: SettingsWrite, db: DbSession) -> SettingsRead:
|
||||
def update_settings(
|
||||
payload: SettingsWrite,
|
||||
db: DbSession,
|
||||
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||
) -> SettingsRead:
|
||||
try:
|
||||
return SettingsService(db).save_settings_snapshot(payload)
|
||||
except ValueError as exc:
|
||||
@@ -84,6 +96,7 @@ def update_settings(payload: SettingsWrite, db: DbSession) -> SettingsRead:
|
||||
def test_model_connectivity(
|
||||
payload: ModelConnectivityTestRequest,
|
||||
db: DbSession,
|
||||
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||
) -> ModelConnectivityTestRead:
|
||||
resolved_payload = payload
|
||||
|
||||
@@ -99,7 +112,10 @@ def test_model_connectivity(
|
||||
"/cache/clear",
|
||||
response_model=SettingsCacheClearRead,
|
||||
summary="清理系统缓存",
|
||||
description="清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存,不删除业务文件或数据库记录。",
|
||||
description=(
|
||||
"清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存,"
|
||||
"不删除业务文件或数据库记录。"
|
||||
),
|
||||
responses={
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
"model": ErrorResponse,
|
||||
@@ -108,7 +124,7 @@ def test_model_connectivity(
|
||||
},
|
||||
)
|
||||
def clear_system_cache(
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||
) -> SettingsCacheClearRead:
|
||||
return SystemCacheService().clear_all()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUserContext, require_admin_user
|
||||
from app.api.deps import CurrentUserContext, require_platform_admin_user
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.system_log import SystemLogEntryRead, SystemLogFileRead, SystemLogTailRead
|
||||
from app.services.system_logs import SystemLogService
|
||||
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/system-logs")
|
||||
},
|
||||
)
|
||||
def list_system_log_entries(
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||
limit: Annotated[int, Query(ge=20, le=1000, description="返回的日志记录数。")] = 300,
|
||||
) -> list[SystemLogEntryRead]:
|
||||
return SystemLogService().list_entries(entry_limit=limit)
|
||||
@@ -49,12 +49,15 @@ def list_system_log_entries(
|
||||
)
|
||||
def get_system_log_entry(
|
||||
entry_id: str,
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||
) -> SystemLogEntryRead:
|
||||
try:
|
||||
return SystemLogService().get_entry(entry_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="日志记录不存在。") from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="日志记录不存在。",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -70,7 +73,7 @@ def get_system_log_entry(
|
||||
},
|
||||
)
|
||||
def list_system_log_files(
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||
) -> list[SystemLogFileRead]:
|
||||
return SystemLogService().list_files()
|
||||
|
||||
@@ -93,10 +96,13 @@ def list_system_log_files(
|
||||
)
|
||||
def get_system_log_tail(
|
||||
file_name: str,
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||
lines: Annotated[int, Query(ge=20, le=1000, description="返回的日志行数。")] = 300,
|
||||
) -> SystemLogTailRead:
|
||||
try:
|
||||
return SystemLogService().read_tail(file_name, line_limit=lines)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="日志文件不存在。") from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="日志文件不存在。",
|
||||
) from exc
|
||||
|
||||
@@ -34,7 +34,11 @@ def build_database_url(payload: BootstrapSetupPayload) -> str:
|
||||
)
|
||||
|
||||
|
||||
def build_bootstrap_state(settings: Settings) -> BootstrapStateRead:
|
||||
def build_bootstrap_state(
|
||||
settings: Settings,
|
||||
*,
|
||||
redact_infrastructure: bool = False,
|
||||
) -> BootstrapStateRead:
|
||||
return BootstrapStateRead(
|
||||
initialized=settings.setup_completed,
|
||||
company={
|
||||
@@ -46,17 +50,23 @@ def build_bootstrap_state(settings: Settings) -> BootstrapStateRead:
|
||||
server={"host": settings.app_host, "port": settings.app_port},
|
||||
database={
|
||||
"driver": "postgresql",
|
||||
"host": settings.postgres_host,
|
||||
"host": "" if redact_infrastructure else settings.postgres_host,
|
||||
"port": settings.postgres_port,
|
||||
"name": settings.postgres_db,
|
||||
"username": settings.postgres_user,
|
||||
"username": "" if redact_infrastructure else settings.postgres_user,
|
||||
"password_configured": bool(settings.postgres_password),
|
||||
},
|
||||
redis={"enabled": bool(settings.redis_url), "url": settings.redis_url or ""},
|
||||
redis={
|
||||
"enabled": bool(settings.redis_url),
|
||||
"url": "" if redact_infrastructure else settings.redis_url or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def persist_bootstrap_config(payload: BootstrapSetupPayload, settings: Settings) -> BootstrapStateRead:
|
||||
def persist_bootstrap_config(
|
||||
payload: BootstrapSetupPayload,
|
||||
settings: Settings,
|
||||
) -> BootstrapStateRead:
|
||||
env_file = ensure_env_file()
|
||||
database_url = build_database_url(payload)
|
||||
vite_api_base_url = f"http://{settings.app_host}:{settings.app_port}{settings.api_v1_prefix}"
|
||||
|
||||
@@ -9,11 +9,9 @@ X-Financial 后端 OpenAPI 文档。
|
||||
|
||||
## 鉴权约定
|
||||
|
||||
- 知识库接口依赖以下请求头模拟当前用户:
|
||||
- `X-Auth-Username`
|
||||
- `X-Auth-Name`
|
||||
- `X-Auth-Role-Codes`
|
||||
- `X-Auth-Is-Admin`
|
||||
- 用户登录成功后,业务接口统一使用:
|
||||
- `Authorization: Bearer <accessToken>`
|
||||
- 用户名、角色和管理员身份均由服务端会话与数据库目录解析,不接受浏览器自报身份。
|
||||
- Agent 资产写接口支持以下审计头:
|
||||
- `X-Actor`
|
||||
- `X-Request-Id`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from app.db.base_class import Base
|
||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||
from app.models.agent_asset import (
|
||||
AgentAsset,
|
||||
AgentAssetReview,
|
||||
@@ -7,14 +6,16 @@ from app.models.agent_asset import (
|
||||
AgentAssetTestRun,
|
||||
AgentAssetVersion,
|
||||
)
|
||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||
from app.models.agent_feedback import AgentOperationFeedback
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.employee import Employee
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.few_shot_sample import FewShotSample
|
||||
from app.models.financial_record import (
|
||||
@@ -53,6 +54,7 @@ __all__ = [
|
||||
"AgentTraceEvent",
|
||||
"ApprovalRecord",
|
||||
"AuditLog",
|
||||
"AuthSession",
|
||||
"BudgetAllocation",
|
||||
"BudgetReservation",
|
||||
"BudgetTransaction",
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
from app.models.agent_asset import (
|
||||
AgentAsset,
|
||||
AgentAssetReview,
|
||||
AgentAssetRuleFeedback,
|
||||
AgentAssetVersion,
|
||||
)
|
||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetRuleFeedback, AgentAssetVersion
|
||||
from app.models.agent_feedback import AgentOperationFeedback
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.employee import Employee
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.few_shot_sample import FewShotSample
|
||||
from app.models.financial_record import (
|
||||
@@ -44,6 +50,7 @@ __all__ = [
|
||||
"AgentTraceEvent",
|
||||
"ApprovalRecord",
|
||||
"AuditLog",
|
||||
"AuthSession",
|
||||
"BudgetAllocation",
|
||||
"BudgetReservation",
|
||||
"BudgetTransaction",
|
||||
|
||||
34
server/src/app/models/auth_session.py
Normal file
34
server/src/app/models/auth_session.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
__tablename__ = "auth_sessions"
|
||||
__table_args__ = (
|
||||
Index("ix_auth_sessions_principal_active", "principal_type", "revoked_at", "expires_at"),
|
||||
Index("ix_auth_sessions_tenant_username", "tenant_id", "username"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
principal_type: Mapped[str] = mapped_column(String(20), index=True)
|
||||
employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(255), index=True)
|
||||
metric_session_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -36,6 +36,14 @@ class LoginResponse(BaseModel):
|
||||
detail: str = "登录成功。"
|
||||
user: AuthUserRead
|
||||
sessionId: str = ""
|
||||
accessToken: str
|
||||
tokenType: str = "Bearer"
|
||||
expiresAt: datetime
|
||||
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
ok: bool = True
|
||||
detail: str = "已退出登录。"
|
||||
|
||||
|
||||
class SessionFinishRequest(BaseModel):
|
||||
@@ -45,6 +53,10 @@ class SessionFinishRequest(BaseModel):
|
||||
pagePath: str = Field(default="", max_length=512)
|
||||
|
||||
|
||||
class LogoutRequest(SessionFinishRequest):
|
||||
sessionId: str = Field(default="", max_length=64)
|
||||
|
||||
|
||||
class SessionFinishResponse(BaseModel):
|
||||
ok: bool = True
|
||||
detail: str = "会话已结算。"
|
||||
|
||||
@@ -39,6 +39,7 @@ logger = get_logger("app.services.agent_foundation")
|
||||
_foundation_ready_lock = threading.RLock()
|
||||
_foundation_ready_keys: set[str] = set()
|
||||
MIGRATION_OWNED_TABLES = {
|
||||
"auth_sessions",
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
|
||||
@@ -11,9 +11,11 @@ from sqlalchemy.orm import Session, selectinload
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import verify_password
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
||||
from app.services.auth_sessions import AuthSessionService
|
||||
from app.services.employee import EmployeeService
|
||||
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
||||
from app.services.settings import SettingsService
|
||||
@@ -50,6 +52,8 @@ class AuthenticatedUser:
|
||||
email: str
|
||||
avatar: str
|
||||
is_admin: bool = False
|
||||
employee_id: str | None = None
|
||||
tenant_id: str = "default"
|
||||
|
||||
|
||||
class AuthService:
|
||||
@@ -79,12 +83,61 @@ class AuthService:
|
||||
raise ValueError("账号或密码错误。")
|
||||
|
||||
def _build_login_response(self, user: AuthenticatedUser) -> LoginResponse:
|
||||
session = UserSessionMetricService(self.db).start_session(user)
|
||||
return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id)
|
||||
settings_snapshot = SettingsService(self.db).get_settings_snapshot()
|
||||
timeout_minutes = settings_snapshot.adminForm.sessionTimeout
|
||||
try:
|
||||
metric_session = UserSessionMetricService(self.db).start_session(user, commit=False)
|
||||
access_token, auth_session = AuthSessionService(self.db).issue(
|
||||
user,
|
||||
metric_session_id=metric_session.session_id,
|
||||
timeout_minutes=timeout_minutes,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(auth_session)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
return LoginResponse(
|
||||
user=self._serialize_user(user),
|
||||
sessionId=metric_session.session_id,
|
||||
accessToken=access_token,
|
||||
expiresAt=auth_session.expires_at,
|
||||
)
|
||||
|
||||
def get_session_user(self, auth_session: AuthSession) -> AuthenticatedUser | None:
|
||||
if auth_session.principal_type == "admin":
|
||||
record = SettingsService(self.db).get_admin_credentials()
|
||||
if record is None:
|
||||
return None
|
||||
allowed_identifiers = {
|
||||
str(record.account or "").strip().casefold(),
|
||||
str(record.email or "").strip().casefold(),
|
||||
}
|
||||
if auth_session.username.strip().casefold() not in allowed_identifiers:
|
||||
return None
|
||||
return self._build_admin_user(record)
|
||||
|
||||
if auth_session.principal_type != "employee":
|
||||
return None
|
||||
|
||||
stmt = select(Employee).options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
)
|
||||
if auth_session.employee_id:
|
||||
stmt = stmt.where(Employee.id == auth_session.employee_id)
|
||||
else:
|
||||
stmt = stmt.where(func.lower(Employee.email) == auth_session.username.lower())
|
||||
employee = self.db.execute(stmt).scalars().first()
|
||||
if employee is None or employee.employment_status == "停用":
|
||||
return None
|
||||
return self._build_employee_user(employee)
|
||||
|
||||
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
|
||||
normalized = identifier.strip()
|
||||
if not normalized or not self.settings.setup_completed:
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
employee = self._find_employee_by_email(normalized)
|
||||
@@ -101,6 +154,10 @@ class AuthService:
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
return self._build_admin_user(record)
|
||||
|
||||
@staticmethod
|
||||
def _build_admin_user(record: Any) -> AuthenticatedUser:
|
||||
admin_username = record.account.strip()
|
||||
admin_email = record.email.strip()
|
||||
display_name = admin_username or admin_email or "系统管理员"
|
||||
@@ -169,7 +226,9 @@ class AuthService:
|
||||
)
|
||||
role_codes = [role.role_code for role in sorted_roles]
|
||||
primary_role_code = role_codes[0] if role_codes else "user"
|
||||
department = employee.organization_unit.name if employee.organization_unit is not None else ""
|
||||
department = (
|
||||
employee.organization_unit.name if employee.organization_unit is not None else ""
|
||||
)
|
||||
manager_name = self._resolve_manager_name(employee)
|
||||
|
||||
return AuthenticatedUser(
|
||||
@@ -189,6 +248,7 @@ class AuthService:
|
||||
email=employee.email,
|
||||
avatar=(employee.name or "?")[:1].upper(),
|
||||
is_admin=False,
|
||||
employee_id=employee.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -235,7 +295,11 @@ class AuthService:
|
||||
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
|
||||
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
|
||||
"recentRiskFlags": recent_risk_flags,
|
||||
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
|
||||
"lastClaimAt": (
|
||||
claims[0].occurred_at.isoformat()
|
||||
if claims and claims[0].occurred_at
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
96
server/src/app/services/auth_sessions.py
Normal file
96
server/src/app/services/auth_sessions.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.auth_session import AuthSession
|
||||
|
||||
DEFAULT_SESSION_TIMEOUT_MINUTES = 30
|
||||
MIN_SESSION_TIMEOUT_MINUTES = 5
|
||||
MAX_SESSION_TIMEOUT_MINUTES = 240
|
||||
|
||||
|
||||
class AuthSessionService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def issue(
|
||||
self,
|
||||
user: Any,
|
||||
*,
|
||||
metric_session_id: str,
|
||||
timeout_minutes: int = DEFAULT_SESSION_TIMEOUT_MINUTES,
|
||||
) -> tuple[str, AuthSession]:
|
||||
now = datetime.now(UTC)
|
||||
normalized_timeout = max(
|
||||
MIN_SESSION_TIMEOUT_MINUTES,
|
||||
min(
|
||||
MAX_SESSION_TIMEOUT_MINUTES,
|
||||
int(timeout_minutes or DEFAULT_SESSION_TIMEOUT_MINUTES),
|
||||
),
|
||||
)
|
||||
access_token = secrets.token_urlsafe(32)
|
||||
auth_session = AuthSession(
|
||||
token_hash=self.hash_token(access_token),
|
||||
tenant_id=str(getattr(user, "tenant_id", "default") or "default").strip() or "default",
|
||||
principal_type="admin" if bool(getattr(user, "is_admin", False)) else "employee",
|
||||
employee_id=str(getattr(user, "employee_id", "") or "").strip() or None,
|
||||
username=str(getattr(user, "username", "") or "").strip(),
|
||||
metric_session_id=str(metric_session_id or "").strip(),
|
||||
issued_at=now,
|
||||
expires_at=now + timedelta(minutes=normalized_timeout),
|
||||
last_seen_at=now,
|
||||
)
|
||||
self.db.add(auth_session)
|
||||
self.db.flush()
|
||||
return access_token, auth_session
|
||||
|
||||
def authenticate(self, access_token: str) -> AuthSession | None:
|
||||
normalized_token = str(access_token or "").strip()
|
||||
if not normalized_token:
|
||||
return None
|
||||
|
||||
auth_session = self.db.scalars(
|
||||
select(AuthSession).where(AuthSession.token_hash == self.hash_token(normalized_token))
|
||||
).first()
|
||||
if auth_session is None or auth_session.revoked_at is not None:
|
||||
return None
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if self._as_utc(auth_session.expires_at) <= now:
|
||||
return None
|
||||
|
||||
auth_session.last_seen_at = now
|
||||
return auth_session
|
||||
|
||||
def revoke(self, session_id: str, *, commit: bool = True) -> bool:
|
||||
normalized_session_id = str(session_id or "").strip()
|
||||
if not normalized_session_id:
|
||||
return False
|
||||
|
||||
auth_session = self.db.get(AuthSession, normalized_session_id)
|
||||
if auth_session is None:
|
||||
return False
|
||||
|
||||
if auth_session.revoked_at is None:
|
||||
auth_session.revoked_at = datetime.now(UTC)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
else:
|
||||
self.db.flush()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def hash_token(access_token: str) -> str:
|
||||
return hashlib.sha256(str(access_token or "").encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
@@ -46,6 +46,7 @@ class UserSessionMetricService:
|
||||
user: Any,
|
||||
*,
|
||||
event: dict[str, Any] | None = None,
|
||||
commit: bool = True,
|
||||
) -> UserSessionMetric:
|
||||
self.ensure_storage_ready()
|
||||
now = datetime.now(UTC)
|
||||
@@ -64,27 +65,36 @@ class UserSessionMetricService:
|
||||
event_json=event or {},
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
else:
|
||||
self.db.flush()
|
||||
return session
|
||||
|
||||
def finish_session(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
expected_username: str = "",
|
||||
reason: str = "manual",
|
||||
last_activity_at: datetime | None = None,
|
||||
activity_event_count: int = 0,
|
||||
event: dict[str, Any] | None = None,
|
||||
commit: bool = True,
|
||||
) -> UserSessionMetric | None:
|
||||
self.ensure_storage_ready()
|
||||
normalized_session_id = str(session_id or "").strip()
|
||||
if not normalized_session_id:
|
||||
return None
|
||||
|
||||
session = self.db.scalars(
|
||||
select(UserSessionMetric).where(UserSessionMetric.session_id == normalized_session_id)
|
||||
).first()
|
||||
stmt = select(UserSessionMetric).where(
|
||||
UserSessionMetric.session_id == normalized_session_id
|
||||
)
|
||||
normalized_username = str(expected_username or "").strip()
|
||||
if normalized_username:
|
||||
stmt = stmt.where(UserSessionMetric.username == normalized_username)
|
||||
session = self.db.scalars(stmt).first()
|
||||
if session is None:
|
||||
return None
|
||||
|
||||
@@ -93,7 +103,11 @@ class UserSessionMetricService:
|
||||
|
||||
logout_at = datetime.now(UTC)
|
||||
session.logout_at = logout_at
|
||||
session.last_activity_at = self._normalize_last_activity(last_activity_at, session.login_at, logout_at)
|
||||
session.last_activity_at = self._normalize_last_activity(
|
||||
last_activity_at,
|
||||
session.login_at,
|
||||
logout_at,
|
||||
)
|
||||
session.duration_ms = self._duration_ms(session.login_at, logout_at)
|
||||
session.activity_event_count = max(0, int(activity_event_count or 0))
|
||||
session.logout_reason = str(reason or "manual").strip()[:40] or "manual"
|
||||
@@ -102,8 +116,11 @@ class UserSessionMetricService:
|
||||
**(session.event_json or {}),
|
||||
"finish": event or {},
|
||||
}
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
else:
|
||||
self.db.flush()
|
||||
return session
|
||||
|
||||
def sum_duration_ms(self, identifiers: set[str], cutoff: datetime) -> int:
|
||||
|
||||
Reference in New Issue
Block a user