Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7f81f1c
fix: 关闭全部 API 文档时禁用公开 Schema
insistence Oct 21, 2026
a71134d
fix: 单独启用文档时提供代理 OpenAPI Schema
insistence Oct 21, 2026
2e2f430
fix: CLI 生产保护使用实际加载的应用环境
insistence Oct 21, 2026
d1b46ba
fix: 无有效角色时默认拒绝数据访问
insistence Oct 21, 2026
8c8de2d
fix: 清洗公告富文本并保护历史公告展示
insistence Oct 21, 2026
099505c
fix: 未知异常响应隐藏内部信息并提供错误标识
insistence Oct 21, 2026
6e04800
fix: 操作日志保留认证与协议异常的响应语义
insistence Oct 21, 2026
faed7fb
fix: 移动请求与上传优先校验 HTTP 错误状态
insistence Oct 21, 2026
a88cad3
fix: 上传回调解析失败和平台错误时可靠结束 Promise
insistence Oct 21, 2026
b44fe77
fix: 上传会话失效后跳转到已注册的登录页面
insistence Oct 21, 2026
3be52bc
fix: 远端退出失败时仍清理移动端本地会话
insistence Oct 21, 2026
c9d7fb4
fix: 请求与上传共用移动端会话失效处理
insistence Oct 21, 2026
754c0e3
fix: CLI 保留任务执行编号与调度同步结果
insistence Oct 21, 2026
ab4219f
fix: 统一移动 WebView 协议校验并避免重复解码
insistence Oct 21, 2026
61bf610
refactor: 将富文本清洗封装为安全工具类
insistence Oct 21, 2026
ed6c85b
fix: 限制用户编辑接口可写字段以保护密码重置权限
insistence Oct 21, 2026
6e0e162
fix: 部门新增和迁移校验目标父部门数据权限
insistence Oct 21, 2026
53838bf
fix: 部门编辑拒绝循环与无效父级并维护祖级关系
insistence Oct 21, 2026
0d60235
fix: 菜单编辑校验父级链并拒绝循环关系
insistence Oct 21, 2026
0fc6000
fix: 远端退出失败时清理 Web 和 React 移动端会话
insistence Oct 21, 2026
8dc6fb2
test: 修正 OAuth 控制器异常响应测试链路
insistence Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions ruoyi-fastapi-backend/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ class CliContext:
"""
CLI 运行上下文。

env: 当前命令使用的环境名称
env: 当前加载的配置文件选择名
app_env: 已加载配置声明的实际应用环境
output: 结果输出格式
operator: 操作者标识,后续用于审计
allow_prod: 是否显式允许生产环境执行危险命令
Expand All @@ -25,6 +26,7 @@ class CliContext:
allow_prod: bool = False
yes: bool = False
dry_run: bool = False
app_env: str | None = None


class CliContextBuilder:
Expand Down Expand Up @@ -53,8 +55,12 @@ def build(
:param dry_run: 是否执行演练模式
:return: 构建完成的 CLI 上下文
"""
# 必须与实际资源配置使用同一来源,命令参数默认值不能覆盖已加载的环境。
from config.env import AppConfig, get_config # noqa: PLC0415 - 保留 CLI 启动时的惰性配置加载

return CliContext(
env=env,
env=get_config.run_env,
app_env=AppConfig.app_env,
output=output,
allow_prod=allow_prod,
yes=yes,
Expand All @@ -66,7 +72,12 @@ def build(

EnvOption = Annotated[
str,
typer.Option('--env', help='运行环境名称', autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values),
typer.Option(
'--env',
envvar='APP_ENV',
help='运行环境配置名称',
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values,
),
]
OutputOption = Annotated[Literal['text', 'json'], typer.Option('--output', help='输出格式,支持 text 或 json')]
AllowProdOption = Annotated[bool, typer.Option('--allow-prod', help='允许在生产环境中执行危险命令')]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,8 @@ def _context(
yes: bool = False,
dry_run: bool = False,
) -> CliContext:
# config.env 可能在应用日志策略时首次导入,因此必须先设置命令选择的环境。
"""
先设置命令环境,再应用危险操作保护并解析操作者。
使用统一执行上下文应用危险操作保护并解析操作者。

:param env: 当前命令运行环境
:param output: 输出格式
Expand All @@ -177,7 +176,6 @@ def _context(
:param dry_run: 是否仅预演而不写入发布状态
:return: 完成环境与操作者设置的 CLI 上下文
"""
os.environ['APP_ENV'] = env
if command_name and not dry_run:
ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, dry_run, command_name=command_name)
else:
Expand Down
5 changes: 3 additions & 2 deletions ruoyi-fastapi-backend/cli/guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ def confirm(self, ctx: CliContext, *, command_name: str) -> CommandResult | None

try:
confirmed = typer.confirm(
f'确认执行危险命令 `{command_name}` 吗? 当前环境:{ctx.env}{"(dry-run)" if ctx.dry_run else ""}',
f'确认执行危险命令 `{command_name}` 吗? 配置:{ctx.env},'
f'应用环境:{ctx.app_env or ctx.env}{"(dry-run)" if ctx.dry_run else ""}',
default=False,
)
except (click.Abort, EOFError, KeyboardInterrupt):
Expand Down Expand Up @@ -173,7 +174,7 @@ def guard(self, ctx: CliContext, *, rule: DangerousCommandRule) -> CommandResult
:return: 拒绝结果或 None
"""
command_name = rule.command_name
if ctx.env == 'prod' and not ctx.allow_prod:
if 'prod' in {ctx.env.strip().lower(), (ctx.app_env or '').strip().lower()} and not ctx.allow_prod:
return self.result_builder.build_guard_reject_result(
f'生产环境默认禁止直接执行危险命令:{command_name}',
'如确认执行,请传入 --allow-prod;如需跳过确认,请同时传入 --yes',
Expand Down
4 changes: 3 additions & 1 deletion ruoyi-fastapi-backend/cli/runtime/job/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,10 @@ async def run_with_scheduler_context(
'operation': operation,
'operationLabel': operation_metadata['operationLabel'],
'targetStatus': status,
'message': operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败',
'message': result.message
or (operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败'),
'serviceMessage': result.message,
'result': result.result,
}

async def run_job_once(self, job_id: int) -> dict[str, Any]:
Expand Down
156 changes: 90 additions & 66 deletions ruoyi-fastapi-backend/common/annotation/log_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
import httpx
from async_lru import alru_cache
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, ORJSONResponse, UJSONResponse
from pydantic_validation_decorator import FieldValidationError
from starlette.exceptions import HTTPException
from starlette.status import HTTP_200_OK
from typing_extensions import ParamSpec
from user_agents import parse
Expand All @@ -20,15 +23,21 @@
from common.enums import BusinessType
from config.env import AppConfig
from exceptions.exception import (
AuthException,
FileRangeNotSatisfiableException,
LoginException,
ModelValidatorException,
OAuthProtocolException,
OidcInteractionException,
PermissionException,
ServiceException,
ServiceWarning,
)
from module_admin.entity.vo.log_vo import LogininforModel, OperLogModel
from module_admin.service.log_service import LogQueueService
from utils.client_ip_util import ClientIPUtil
from utils.dependency_util import DependencyUtil
from utils.exception_util import ExceptionUtil
from utils.log_util import LogSanitizer, logger
from utils.response_util import ResponseUtil
from utils.time_util import TimezoneUtil
Expand Down Expand Up @@ -193,79 +202,94 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
oper_time = TimezoneUtil.utc_now()
# 此处在登录之前向原始函数传递一些登录信息,用于监测在线用户的相关信息
login_log = self._get_login_log(user_agent, oper_ip, oper_location, oper_time, kwargs)
raised_exception = None
try:
# 调用原始函数
result = await func(*args, **kwargs)
except (LoginException, ServiceWarning) as e:
logger.warning(e.message)
result = ResponseUtil.failure(data=e.data, msg=e.message)
except ServiceException as e:
logger.error(e.message)
result = ResponseUtil.error(data=e.data, msg=e.message)
except FileRangeNotSatisfiableException:
raise
except (
AuthException,
PermissionException,
LoginException,
ServiceException,
ServiceWarning,
ModelValidatorException,
FieldValidationError,
FileRangeNotSatisfiableException,
HTTPException,
RequestValidationError,
OAuthProtocolException,
OidcInteractionException,
) as e:
# 已知异常仅构造审计摘要,实际响应仍交由全局异常处理器生成。
raised_exception = e
result = ResponseUtil.error(msg=getattr(e, 'message', None) or str(e))
except Exception as e:
logger.exception(e)
result = ResponseUtil.error(msg=str(e))
# 获取请求耗时
cost_time = float(time.perf_counter() - start_time) * 1000
# 判断请求是否来自api文档
request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request)
# 根据响应结果的类型使用不同的方法获取响应结果参数
sanitized_result_dict = LogSanitizer.sanitize_data(
self._get_result_dict(result, request_from_swagger, request_from_redoc)
)
json_result = self._build_log_text(
sanitized_result_dict,
self.response_log_mode,
self.response_include_fields,
self.response_exclude_fields,
payload_kind='response',
)
# 日志表返回参数字段长度最大为2000,因此在此处判断长度
json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长')
# 根据响应结果获取响应状态及异常信息
status, error_msg = self._get_status_and_error_msg(sanitized_result_dict)
# 根据日志类型向对应的日志表插入数据
if self.log_type == 'login':
# 登录请求来自于api文档时不记录登录日志,其余情况则记录
if request_from_swagger or request_from_redoc:
pass
result = ExceptionUtil.handle_unexpected_exception(e)
try:
# 获取请求耗时
cost_time = float(time.perf_counter() - start_time) * 1000
# 判断请求是否来自api文档
request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request)
# 根据响应结果的类型使用不同的方法获取响应结果参数
sanitized_result_dict = LogSanitizer.sanitize_data(
self._get_result_dict(result, request_from_swagger, request_from_redoc)
)
json_result = self._build_log_text(
sanitized_result_dict,
self.response_log_mode,
self.response_include_fields,
self.response_exclude_fields,
payload_kind='response',
)
# 日志表返回参数字段长度最大为2000,因此在此处判断长度
json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长')
# 根据响应结果获取响应状态及异常信息
status, error_msg = self._get_status_and_error_msg(sanitized_result_dict)
# 根据日志类型向对应的日志表插入数据
if self.log_type == 'login':
# 登录请求来自于api文档时不记录登录日志,其余情况则记录
if request_from_swagger or request_from_redoc:
pass
else:
user = kwargs.get('form_data')
login_log.update(
{
'loginTime': oper_time,
'userName': user.username,
'status': str(status),
'msg': sanitized_result_dict.get('msg') or '',
}
)

await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path)
else:
user = kwargs.get('form_data')
login_log.update(
{
'loginTime': oper_time,
'userName': user.username,
'status': str(status),
'msg': sanitized_result_dict.get('msg') or '',
}
current_user = RequestContext.get_current_user()
oper_name = current_user.user.user_name
dept_name = current_user.user.dept.dept_name if current_user.user.dept else None
operation_log = OperLogModel(
title=self.title,
businessType=self.business_type,
method=func_path,
requestMethod=request_method,
operatorType=operator_type,
operName=oper_name,
deptName=dept_name,
operUrl=oper_url,
operIp=oper_ip,
operLocation=oper_location,
operParam=oper_param,
jsonResult=json_result,
status=status,
errorMsg=error_msg,
operTime=oper_time,
costTime=int(cost_time),
)
await LogQueueService.enqueue_operation_log(request, operation_log, func_path)

await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path)
else:
current_user = RequestContext.get_current_user()
oper_name = current_user.user.user_name
dept_name = current_user.user.dept.dept_name if current_user.user.dept else None
operation_log = OperLogModel(
title=self.title,
businessType=self.business_type,
method=func_path,
requestMethod=request_method,
operatorType=operator_type,
operName=oper_name,
deptName=dept_name,
operUrl=oper_url,
operIp=oper_ip,
operLocation=oper_location,
operParam=oper_param,
jsonResult=json_result,
status=status,
errorMsg=error_msg,
operTime=oper_time,
costTime=int(cost_time),
)
await LogQueueService.enqueue_operation_log(request, operation_log, func_path)
finally:
# 即使审计写入失败,也不能覆盖原有认证或协议异常。
if raised_exception is not None:
raise raised_exception

return result

Expand Down
15 changes: 8 additions & 7 deletions ruoyi-fastapi-backend/common/aspect/data_scope.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fastapi import Depends, Request, params
from sqlalchemy import ColumnElement, func, or_, select
from sqlalchemy import ColumnElement, false, func, or_, select, true

from common.context import RequestContext
from config.database import Base
Expand Down Expand Up @@ -39,14 +39,15 @@ def __init__(
def __call__(self, request: Request) -> ColumnElement:
DependencyUtil.check_exclude_routes(request, err_msg='当前路由不在认证规则内,不可使用GetDataScope依赖项')
current_user = RequestContext.get_current_user()
if current_user.user.admin:
return true()
user_id = current_user.user.user_id
dept_id = current_user.user.dept_id
custom_data_scope_role_id_list = [
item.role_id for item in current_user.user.role if item.data_scope == self.DATA_SCOPE_CUSTOM
]
roles = [role for role in (current_user.user.role or []) if role.status == '0' and role.del_flag == '0']
custom_data_scope_role_id_list = [item.role_id for item in roles if item.data_scope == self.DATA_SCOPE_CUSTOM]
param_sql_list = []
for role in current_user.user.role:
if current_user.user.admin or role.data_scope == self.DATA_SCOPE_ALL:
for role in roles:
if role.data_scope == self.DATA_SCOPE_ALL:
param_sql_list = [True]
break
if role.data_scope == self.DATA_SCOPE_CUSTOM:
Expand Down Expand Up @@ -91,7 +92,7 @@ def __call__(self, request: Request) -> ColumnElement:
else:
param_sql_list.append(False)
param_sql_list = list(dict.fromkeys(param_sql_list))
param_sql = or_(*param_sql_list)
param_sql = or_(false(), *param_sql_list)

return param_sql

Expand Down
10 changes: 6 additions & 4 deletions ruoyi-fastapi-backend/exceptions/handle.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from urllib.parse import urlsplit

from fastapi import FastAPI, Request, Response, status
from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse as FastAPIJSONResponse
from fastapi.responses import RedirectResponse
from pydantic_validation_decorator import FieldValidationError
from starlette.exceptions import HTTPException

from exceptions.exception import (
AuthException,
Expand All @@ -17,6 +17,7 @@
ServiceException,
ServiceWarning,
)
from utils.exception_util import ExceptionUtil
from utils.log_util import logger
from utils.oidc_util import OidcUtil
from utils.response_util import JSONResponse, ResponseUtil, jsonable_encoder
Expand Down Expand Up @@ -118,14 +119,15 @@ async def file_range_not_satisfiable_exception_handler(
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
return JSONResponse(
content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}), status_code=exc.status_code
content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}),
status_code=exc.status_code,
headers=exc.headers,
)

# 处理其他异常
@app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception) -> Response:
logger.exception(exc)
return ResponseUtil.error(msg=str(exc))
return ExceptionUtil.handle_unexpected_exception(exc)


def _build_oauth_redirect(exc: OAuthProtocolException) -> Response:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ async def add_system_dept(
add_dept: DeptModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
if not current_user.user.admin:
await DeptService.check_dept_data_scope_services(query_db, add_dept.parent_id, data_scope_sql)
add_dept.create_by = current_user.user.user_name
add_dept.update_by = current_user.user.user_name
add_dept_result = await DeptService.add_dept_services(query_db, add_dept)
Expand Down Expand Up @@ -111,6 +114,9 @@ async def edit_system_dept(
) -> Response:
if not current_user.user.admin:
await DeptService.check_dept_data_scope_services(query_db, edit_dept.dept_id, data_scope_sql)
dept_info = await DeptService.dept_detail_services(query_db, edit_dept.dept_id)
if edit_dept.parent_id is not None and edit_dept.parent_id != dept_info.parent_id:
await DeptService.check_dept_data_scope_services(query_db, edit_dept.parent_id, data_scope_sql)
edit_dept.update_by = current_user.user.user_name
edit_dept_result = await DeptService.edit_dept_services(query_db, edit_dept)
logger.info(edit_dept_result.message)
Expand Down
Loading
Loading