import { getNotice } from '@/api/system/notice'
+import { sanitizeNoticeHtml } from '@/utils/noticeHtml'
export default {
name: 'NoticeDetailView',
@@ -98,6 +99,9 @@ export default {
}
},
computed: {
+ safeContent() {
+ return sanitizeNoticeHtml(this.detail && this.detail.noticeContent)
+ },
isStatusNormal() {
const s = this.detail && this.detail.status
return s === '0' || s === 0
diff --git a/ruoyi-fastapi-frontend/vue2/web/src/utils/noticeHtml.js b/ruoyi-fastapi-frontend/vue2/web/src/utils/noticeHtml.js
new file mode 100644
index 000000000..709a2c5f7
--- /dev/null
+++ b/ruoyi-fastapi-frontend/vue2/web/src/utils/noticeHtml.js
@@ -0,0 +1,71 @@
+import createDOMPurify from 'dompurify'
+
+const purifier = createDOMPurify(window)
+purifier.addHook('uponSanitizeAttribute', (node, data) => {
+ if (
+ node.tagName === 'IFRAME' &&
+ data.attrName === 'src' &&
+ !/^https?:\/\/[^/?#\s]+/i.test(data.attrValue)
+ ) {
+ data.keepAttr = false
+ }
+ if (data.attrName === 'href' || data.attrName === 'src') {
+ // 显式限制包括图片 data URI 在内的所有链接协议。
+ const value = data.attrValue.replace(/[\u0000-\u0020\u007f-\u009f]/g, '')
+ const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i)
+ if (scheme && !['http', 'https', 'mailto'].includes(scheme[1].toLowerCase())) {
+ data.keepAttr = false
+ }
+ }
+})
+
+purifier.addHook('afterSanitizeAttributes', (node) => {
+ if (node.tagName === 'IFRAME') {
+ // 保留 Quill 视频能力,禁止嵌入页面访问父页面及同源存储。
+ node.setAttribute('sandbox', 'allow-scripts allow-presentation')
+ node.setAttribute('referrerpolicy', 'no-referrer')
+ }
+ if (!node.hasAttribute('style')) return
+ // 保留编辑器使用的文本格式,剔除定位、背景资源等非正文样式。
+ const properties = ['color', 'background-color', 'font-size', 'font-family', 'text-align']
+ const styles = properties.map((property) => [property, node.style.getPropertyValue(property)])
+ node.removeAttribute('style')
+ for (const [property, value] of styles) {
+ if (value) node.style.setProperty(property, value)
+ }
+})
+
+/**
+ * 清洗公告正文,与后端 notice_html_util 的富文本白名单保持一致。
+ * @param {string} content 原始公告正文
+ * @returns {string} 可安全渲染的公告正文
+ */
+export function sanitizeNoticeHtml(content) {
+ return purifier.sanitize(content || '', {
+ ALLOWED_TAGS:
+ 'p br div span h1 h2 h3 h4 h5 h6 strong b em i u s strike sub sup blockquote pre code ul ol li hr table thead tbody tfoot tr th td a img iframe'.split(
+ ' '
+ ),
+ ALLOWED_ATTR: [
+ 'class',
+ 'title',
+ 'style',
+ 'href',
+ 'src',
+ 'alt',
+ 'width',
+ 'height',
+ 'colspan',
+ 'rowspan',
+ 'start',
+ 'rel',
+ 'sandbox',
+ 'referrerpolicy',
+ 'frameborder',
+ 'allowfullscreen',
+ ],
+ ALLOW_DATA_ATTR: false,
+ ALLOW_ARIA_ATTR: false,
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
+ })
+}
diff --git a/ruoyi-fastapi-frontend/vue3/web/package.json b/ruoyi-fastapi-frontend/vue3/web/package.json
index 315e809de..4bc6a489c 100644
--- a/ruoyi-fastapi-frontend/vue3/web/package.json
+++ b/ruoyi-fastapi-frontend/vue3/web/package.json
@@ -37,6 +37,7 @@
"axios": "1.20.0",
"clipboard": "2.0.11",
"dayjs": "1.11.23",
+ "dompurify": "3.4.17",
"echarts": "6.1.0",
"element-plus": "2.14.7",
"file-saver": "2.0.5",
diff --git a/ruoyi-fastapi-frontend/vue3/web/pnpm-lock.yaml b/ruoyi-fastapi-frontend/vue3/web/pnpm-lock.yaml
index b0b2cdf33..be56e948b 100644
--- a/ruoyi-fastapi-frontend/vue3/web/pnpm-lock.yaml
+++ b/ruoyi-fastapi-frontend/vue3/web/pnpm-lock.yaml
@@ -44,6 +44,9 @@ importers:
dayjs:
specifier: 1.11.23
version: 1.11.23
+ dompurify:
+ specifier: 3.4.17
+ version: 3.4.17
echarts:
specifier: 6.1.0
version: 6.1.0
diff --git a/ruoyi-fastapi-frontend/vue3/web/src/layout/components/HeaderNotice/DetailView.vue b/ruoyi-fastapi-frontend/vue3/web/src/layout/components/HeaderNotice/DetailView.vue
index 6d8a8aecc..e68526861 100644
--- a/ruoyi-fastapi-frontend/vue3/web/src/layout/components/HeaderNotice/DetailView.vue
+++ b/ruoyi-fastapi-frontend/vue3/web/src/layout/components/HeaderNotice/DetailView.vue
@@ -71,7 +71,7 @@
import { getNotice } from '@/api/system/notice'
+import { sanitizeNoticeHtml } from '@/utils/noticeHtml'
const visible = ref(false)
const loading = ref(false)
const detail = ref(null)
+const safeContent = computed(() => sanitizeNoticeHtml(detail.value?.noticeContent))
+
const isStatusNormal = computed(() => {
const status = detail.value && detail.value.status
return status === '0' || status === 0
diff --git a/ruoyi-fastapi-frontend/vue3/web/src/utils/noticeHtml.js b/ruoyi-fastapi-frontend/vue3/web/src/utils/noticeHtml.js
new file mode 100644
index 000000000..709a2c5f7
--- /dev/null
+++ b/ruoyi-fastapi-frontend/vue3/web/src/utils/noticeHtml.js
@@ -0,0 +1,71 @@
+import createDOMPurify from 'dompurify'
+
+const purifier = createDOMPurify(window)
+purifier.addHook('uponSanitizeAttribute', (node, data) => {
+ if (
+ node.tagName === 'IFRAME' &&
+ data.attrName === 'src' &&
+ !/^https?:\/\/[^/?#\s]+/i.test(data.attrValue)
+ ) {
+ data.keepAttr = false
+ }
+ if (data.attrName === 'href' || data.attrName === 'src') {
+ // 显式限制包括图片 data URI 在内的所有链接协议。
+ const value = data.attrValue.replace(/[\u0000-\u0020\u007f-\u009f]/g, '')
+ const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i)
+ if (scheme && !['http', 'https', 'mailto'].includes(scheme[1].toLowerCase())) {
+ data.keepAttr = false
+ }
+ }
+})
+
+purifier.addHook('afterSanitizeAttributes', (node) => {
+ if (node.tagName === 'IFRAME') {
+ // 保留 Quill 视频能力,禁止嵌入页面访问父页面及同源存储。
+ node.setAttribute('sandbox', 'allow-scripts allow-presentation')
+ node.setAttribute('referrerpolicy', 'no-referrer')
+ }
+ if (!node.hasAttribute('style')) return
+ // 保留编辑器使用的文本格式,剔除定位、背景资源等非正文样式。
+ const properties = ['color', 'background-color', 'font-size', 'font-family', 'text-align']
+ const styles = properties.map((property) => [property, node.style.getPropertyValue(property)])
+ node.removeAttribute('style')
+ for (const [property, value] of styles) {
+ if (value) node.style.setProperty(property, value)
+ }
+})
+
+/**
+ * 清洗公告正文,与后端 notice_html_util 的富文本白名单保持一致。
+ * @param {string} content 原始公告正文
+ * @returns {string} 可安全渲染的公告正文
+ */
+export function sanitizeNoticeHtml(content) {
+ return purifier.sanitize(content || '', {
+ ALLOWED_TAGS:
+ 'p br div span h1 h2 h3 h4 h5 h6 strong b em i u s strike sub sup blockquote pre code ul ol li hr table thead tbody tfoot tr th td a img iframe'.split(
+ ' '
+ ),
+ ALLOWED_ATTR: [
+ 'class',
+ 'title',
+ 'style',
+ 'href',
+ 'src',
+ 'alt',
+ 'width',
+ 'height',
+ 'colspan',
+ 'rowspan',
+ 'start',
+ 'rel',
+ 'sandbox',
+ 'referrerpolicy',
+ 'frameborder',
+ 'allowfullscreen',
+ ],
+ ALLOW_DATA_ATTR: false,
+ ALLOW_ARIA_ATTR: false,
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
+ })
+}
From 099505c400ad2ea4feda87eba2b47fb9f6538312 Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:28:29 +0800
Subject: [PATCH 06/21] =?UTF-8?q?fix:=20=E6=9C=AA=E7=9F=A5=E5=BC=82?=
=?UTF-8?q?=E5=B8=B8=E5=93=8D=E5=BA=94=E9=9A=90=E8=97=8F=E5=86=85=E9=83=A8?=
=?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=B9=B6=E6=8F=90=E4=BE=9B=E9=94=99=E8=AF=AF?=
=?UTF-8?q?=E6=A0=87=E8=AF=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../common/annotation/log_annotation.py | 4 +-
ruoyi-fastapi-backend/exceptions/handle.py | 4 +-
.../tests/common/test_exception_responses.py | 54 +++++++++++++++++++
ruoyi-fastapi-backend/utils/exception_util.py | 28 ++++++++++
4 files changed, 86 insertions(+), 4 deletions(-)
create mode 100644 ruoyi-fastapi-backend/tests/common/test_exception_responses.py
create mode 100644 ruoyi-fastapi-backend/utils/exception_util.py
diff --git a/ruoyi-fastapi-backend/common/annotation/log_annotation.py b/ruoyi-fastapi-backend/common/annotation/log_annotation.py
index 6353d0c57..47763aa4a 100644
--- a/ruoyi-fastapi-backend/common/annotation/log_annotation.py
+++ b/ruoyi-fastapi-backend/common/annotation/log_annotation.py
@@ -29,6 +29,7 @@
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
@@ -205,8 +206,7 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
except FileRangeNotSatisfiableException:
raise
except Exception as e:
- logger.exception(e)
- result = ResponseUtil.error(msg=str(e))
+ result = ExceptionUtil.handle_unexpected_exception(e)
# 获取请求耗时
cost_time = float(time.perf_counter() - start_time) * 1000
# 判断请求是否来自api文档
diff --git a/ruoyi-fastapi-backend/exceptions/handle.py b/ruoyi-fastapi-backend/exceptions/handle.py
index 996fb0d36..a20bd36d8 100644
--- a/ruoyi-fastapi-backend/exceptions/handle.py
+++ b/ruoyi-fastapi-backend/exceptions/handle.py
@@ -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
@@ -124,8 +125,7 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> Respon
# 处理其他异常
@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:
diff --git a/ruoyi-fastapi-backend/tests/common/test_exception_responses.py b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py
new file mode 100644
index 000000000..2551c7fc1
--- /dev/null
+++ b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py
@@ -0,0 +1,54 @@
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi import FastAPI, Request
+from fastapi.testclient import TestClient
+
+from common.annotation.log_annotation import Log
+from common.constant import HttpStatusConstant
+from common.context import RequestContext
+from common.enums import BusinessType
+from exceptions.handle import handle_exception
+from module_admin.service.log_service import LogQueueService
+from utils.dependency_util import DependencyUtil
+
+
+@pytest.fixture
+def log_context(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
+ monkeypatch.setattr(DependencyUtil, 'check_exclude_routes', lambda *args, **kwargs: None)
+ monkeypatch.setattr(Log, '_get_oper_location', AsyncMock(return_value='测试'))
+ monkeypatch.setattr(
+ RequestContext,
+ 'get_current_user',
+ lambda: SimpleNamespace(
+ user=SimpleNamespace(user_name='tester', dept=None),
+ ),
+ )
+ monkeypatch.setattr(LogQueueService, 'enqueue_operation_log', AsyncMock())
+ diagnostic_logger = MagicMock()
+ monkeypatch.setattr('utils.exception_util.logger', diagnostic_logger)
+ return diagnostic_logger
+
+
+@pytest.mark.parametrize('decorated', [False, True])
+def test_unexpected_error_is_safe_and_correlates_with_server_log(log_context: MagicMock, decorated: bool) -> None:
+ app = FastAPI()
+ handle_exception(app)
+ error = RuntimeError('SELECT secret FROM users; password=private-marker /srv/private/config')
+
+ async def endpoint(request: Request) -> None:
+ raise error
+
+ app.get('/failure')(Log(title='测试', business_type=BusinessType.OTHER)(endpoint) if decorated else endpoint)
+ response = TestClient(app, raise_server_exceptions=False).get('/failure')
+ payload = response.json()
+ assert payload['code'] == HttpStatusConstant.ERROR
+ assert payload['msg'] == '服务暂时不可用,请稍后重试'
+ assert 'private-marker' not in response.text
+ assert 'SELECT' not in response.text
+ assert '/srv/private' not in response.text
+ error_id = payload['data']['errorId']
+ assert response.headers['X-Error-Id'] == error_id
+ log_context.bind.assert_called_once_with(error_id=error_id)
+ log_context.bind.return_value.opt.assert_called_once_with(exception=error)
diff --git a/ruoyi-fastapi-backend/utils/exception_util.py b/ruoyi-fastapi-backend/utils/exception_util.py
new file mode 100644
index 000000000..2dc5b2137
--- /dev/null
+++ b/ruoyi-fastapi-backend/utils/exception_util.py
@@ -0,0 +1,28 @@
+from uuid import uuid4
+
+from fastapi import Response
+
+from utils.log_util import logger
+from utils.response_util import ResponseUtil
+
+
+class ExceptionUtil:
+ """
+ 未知异常的安全响应与诊断工具类。
+ """
+
+ @staticmethod
+ def handle_unexpected_exception(exc: Exception) -> Response:
+ """
+ 记录脱敏诊断并返回不包含内部异常信息的统一响应。
+
+ :param exc: 未知异常对象
+ :return: 包含错误关联标识的安全响应
+ """
+ error_id = uuid4().hex
+ logger.bind(error_id=error_id).opt(exception=exc).error('请求处理失败,错误标识:{}', error_id)
+ return ResponseUtil.error(
+ msg='服务暂时不可用,请稍后重试',
+ data={'errorId': error_id},
+ headers={'X-Error-Id': error_id},
+ )
From 6e04800fea5d1986f3378decec415f0948ac3f8b Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:29:54 +0800
Subject: [PATCH 07/21] =?UTF-8?q?fix:=20=E6=93=8D=E4=BD=9C=E6=97=A5?=
=?UTF-8?q?=E5=BF=97=E4=BF=9D=E7=95=99=E8=AE=A4=E8=AF=81=E4=B8=8E=E5=8D=8F?=
=?UTF-8?q?=E8=AE=AE=E5=BC=82=E5=B8=B8=E7=9A=84=E5=93=8D=E5=BA=94=E8=AF=AD?=
=?UTF-8?q?=E4=B9=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../common/annotation/log_annotation.py | 152 ++++++++++--------
ruoyi-fastapi-backend/exceptions/handle.py | 6 +-
.../tests/common/test_exception_responses.py | 59 ++++++-
3 files changed, 150 insertions(+), 67 deletions(-)
diff --git a/ruoyi-fastapi-backend/common/annotation/log_annotation.py b/ruoyi-fastapi-backend/common/annotation/log_annotation.py
index 47763aa4a..a5db0caee 100644
--- a/ruoyi-fastapi-backend/common/annotation/log_annotation.py
+++ b/ruoyi-fastapi-backend/common/annotation/log_annotation.py
@@ -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
@@ -20,8 +23,13 @@
from common.enums import BusinessType
from config.env import AppConfig
from exceptions.exception import (
+ AuthException,
FileRangeNotSatisfiableException,
LoginException,
+ ModelValidatorException,
+ OAuthProtocolException,
+ OidcInteractionException,
+ PermissionException,
ServiceException,
ServiceWarning,
)
@@ -194,78 +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:
result = ExceptionUtil.handle_unexpected_exception(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
+ 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
diff --git a/ruoyi-fastapi-backend/exceptions/handle.py b/ruoyi-fastapi-backend/exceptions/handle.py
index a20bd36d8..7bad197dc 100644
--- a/ruoyi-fastapi-backend/exceptions/handle.py
+++ b/ruoyi-fastapi-backend/exceptions/handle.py
@@ -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,
@@ -119,7 +119,9 @@ 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,
)
# 处理其他异常
diff --git a/ruoyi-fastapi-backend/tests/common/test_exception_responses.py b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py
index 2551c7fc1..29cbcc2e9 100644
--- a/ruoyi-fastapi-backend/tests/common/test_exception_responses.py
+++ b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py
@@ -2,13 +2,22 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
-from fastapi import FastAPI, Request
+from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
from common.annotation.log_annotation import Log
from common.constant import HttpStatusConstant
from common.context import RequestContext
from common.enums import BusinessType
+from exceptions.exception import (
+ AuthException,
+ FileRangeNotSatisfiableException,
+ LoginException,
+ OAuthProtocolException,
+ OidcInteractionException,
+ PermissionException,
+ ServiceException,
+)
from exceptions.handle import handle_exception
from module_admin.service.log_service import LogQueueService
from utils.dependency_util import DependencyUtil
@@ -52,3 +61,51 @@ async def endpoint(request: Request) -> None:
assert response.headers['X-Error-Id'] == error_id
log_context.bind.assert_called_once_with(error_id=error_id)
log_context.bind.return_value.opt.assert_called_once_with(exception=error)
+
+
+@pytest.mark.parametrize(
+ 'exception_kind', ['auth', 'permission', 'http', 'oauth', 'redirect', 'oidc', 'login', 'service', 'range']
+)
+@pytest.mark.parametrize('audit_fails', [False, True])
+def test_logged_known_errors_preserve_global_response(
+ log_context: MagicMock, monkeypatch: pytest.MonkeyPatch, exception_kind: str, audit_fails: bool
+) -> None:
+ exceptions = {
+ 'auth': AuthException(message='令牌失效'),
+ 'permission': PermissionException(message='无权限'),
+ 'http': HTTPException(401, '凭证无效', headers={'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store'}),
+ 'oauth': OAuthProtocolException('invalid_client'),
+ 'redirect': OAuthProtocolException(
+ 'access_denied', redirect_uri='https://client.example/callback', redirect_uri_verified=True, state='opaque'
+ ),
+ 'oidc': OidcInteractionException(interaction_id='expired'),
+ 'login': LoginException(message='登录失败'),
+ 'service': ServiceException(message='操作失败'),
+ 'range': FileRangeNotSatisfiableException(20),
+ }
+ app = FastAPI()
+ handle_exception(app)
+
+ async def endpoint(request: Request) -> None:
+ raise exceptions[exception_kind]
+
+ app.get('/plain')(endpoint)
+ app.get('/logged')(Log(title='测试', business_type=BusinessType.OTHER)(endpoint))
+ if audit_fails:
+ monkeypatch.setattr(
+ LogQueueService, 'enqueue_operation_log', AsyncMock(side_effect=RuntimeError('queue unavailable'))
+ )
+ client = TestClient(app, raise_server_exceptions=False)
+ plain = client.get('/plain', follow_redirects=False)
+ logged = client.get('/logged', follow_redirects=False)
+ assert logged.status_code == plain.status_code
+ for name in ['location', 'www-authenticate', 'cache-control', 'pragma', 'content-range']:
+ assert logged.headers.get(name) == plain.headers.get(name)
+ if plain.content:
+ expected, actual = plain.json(), logged.json()
+ expected.pop('time', None)
+ actual.pop('time', None)
+ assert actual == expected
+ else:
+ assert not logged.content
+ LogQueueService.enqueue_operation_log.assert_awaited_once()
From faed7fb66ba885add33826278b26b1e6f5877c89 Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:33:33 +0800
Subject: [PATCH 08/21] =?UTF-8?q?fix:=20=E7=A7=BB=E5=8A=A8=E8=AF=B7?=
=?UTF-8?q?=E6=B1=82=E4=B8=8E=E4=B8=8A=E4=BC=A0=E4=BC=98=E5=85=88=E6=A0=A1?=
=?UTF-8?q?=E9=AA=8C=20HTTP=20=E9=94=99=E8=AF=AF=E7=8A=B6=E6=80=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../vue2/mobile/src/utils/request.js | 5 +-
.../vue2/mobile/src/utils/response.js | 24 +++++++
.../vue2/mobile/src/utils/upload.js | 5 +-
.../vue3/mobile/src/utils/request.js | 5 +-
.../vue3/mobile/src/utils/response.js | 24 +++++++
.../vue3/mobile/src/utils/upload.js | 5 +-
.../frontend/mobile-behavior.mjs | 71 ++++++++++++++++---
.../frontend/mobile-harness.mjs | 40 +++++++++--
8 files changed, 154 insertions(+), 25 deletions(-)
create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/response.js
create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/response.js
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js
index 2c46c5177..0066b075a 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js
@@ -2,7 +2,7 @@ import { getDisplayTimezone } from '@/utils/time'
import store from '@/store'
import config from '@/config'
import { getToken } from '@/utils/auth'
-import errorCode from '@/utils/errorCode'
+import { getResponseStatus } from '@/utils/response'
import { toast, showConfirm, tansParams } from '@/utils/common'
import {
decryptTransportErrorResponse,
@@ -66,8 +66,7 @@ const request = async (config) => {
return
}
- const code = res.data.code || 200
- const msg = errorCode[code] || res.data.msg || errorCode['default']
+ const { code, msg } = getResponseStatus(res, res.data)
if (code === 401) {
showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
if (res.confirm) {
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/response.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/response.js
new file mode 100644
index 000000000..31574a1fa
--- /dev/null
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/response.js
@@ -0,0 +1,24 @@
+import errorCode from '@/utils/errorCode'
+
+/**
+ * 统一解析 HTTP 状态和业务码,HTTP 失败优先于响应体中的成功码。
+ * @param {Object} response 平台原始响应
+ * @param {Object} data 已解析或解密的业务响应
+ * @returns {{code: number, msg: string}} 统一响应状态
+ */
+export function getResponseStatus(response, data) {
+ const status = Number(response.statusCode ?? response.status ?? 200)
+ const code = status < 200 || status >= 300 ? status : Number(data?.code ?? 200)
+ const detail = data?.detail
+ const detailMessage =
+ typeof detail === 'string'
+ ? detail
+ : Array.isArray(detail)
+ ? detail
+ .map((item) => item.msg)
+ .filter(Boolean)
+ .join(';')
+ : ''
+ const msg = data?.msg || detailMessage || errorCode[code] || errorCode['default']
+ return { code, msg }
+}
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
index 50219b259..76b916b69 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
@@ -1,7 +1,7 @@
import store from '@/store'
import config from '@/config'
import { getToken } from '@/utils/auth'
-import errorCode from '@/utils/errorCode'
+import { getResponseStatus } from '@/utils/response'
import { toast, showConfirm, tansParams } from '@/utils/common'
let timeout = 10000
@@ -30,8 +30,7 @@ const upload = (config) => {
formData: config.formData,
success: (res) => {
let result = JSON.parse(res.data)
- const code = result.code || 200
- const msg = errorCode[code] || result.msg || errorCode['default']
+ const { code, msg } = getResponseStatus(res, result)
if (code === 200) {
resolve(result)
} else if (code == 401) {
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js
index e1298e5be..2065a5fe8 100644
--- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js
@@ -1,7 +1,7 @@
import config from '@/config'
import { getToken } from '@/utils/auth'
import { getDisplayTimezone } from '@/utils/time'
-import errorCode from '@/utils/errorCode'
+import { getResponseStatus } from '@/utils/response'
import { useUserStore } from '@/store/modules/user'
import { toast, showConfirm, tansParams } from '@/utils/common'
import {
@@ -57,8 +57,7 @@ const request = async (config) => {
return
}
- const code = res.data.code || 200
- const msg = errorCode[code] || res.data.msg || errorCode['default']
+ const { code, msg } = getResponseStatus(res, res.data)
if (code === 401) {
showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
if (res.confirm) {
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/response.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/response.js
new file mode 100644
index 000000000..31574a1fa
--- /dev/null
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/response.js
@@ -0,0 +1,24 @@
+import errorCode from '@/utils/errorCode'
+
+/**
+ * 统一解析 HTTP 状态和业务码,HTTP 失败优先于响应体中的成功码。
+ * @param {Object} response 平台原始响应
+ * @param {Object} data 已解析或解密的业务响应
+ * @returns {{code: number, msg: string}} 统一响应状态
+ */
+export function getResponseStatus(response, data) {
+ const status = Number(response.statusCode ?? response.status ?? 200)
+ const code = status < 200 || status >= 300 ? status : Number(data?.code ?? 200)
+ const detail = data?.detail
+ const detailMessage =
+ typeof detail === 'string'
+ ? detail
+ : Array.isArray(detail)
+ ? detail
+ .map((item) => item.msg)
+ .filter(Boolean)
+ .join(';')
+ : ''
+ const msg = data?.msg || detailMessage || errorCode[code] || errorCode['default']
+ return { code, msg }
+}
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
index 46471afbf..01bc7c1c8 100644
--- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
@@ -1,7 +1,7 @@
import { useUserStore } from '@/store'
import config from '@/config'
import { getToken } from '@/utils/auth'
-import errorCode from '@/utils/errorCode'
+import { getResponseStatus } from '@/utils/response'
import { toast, showConfirm, tansParams } from '@/utils/common'
let timeout = 10000
@@ -30,8 +30,7 @@ export default function upload(config) {
formData: config.formData,
success: (res) => {
let result = JSON.parse(res.data)
- const code = result.code || 200
- const msg = errorCode[code] || result.msg || errorCode['default']
+ const { code, msg } = getResponseStatus(res, result)
if (code === 200) {
resolve(result)
} else if (code == 401) {
diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
index eee5b793d..240f303f0 100644
--- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
+++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
@@ -3,8 +3,45 @@ import test from 'node:test'
import { createMobileHarness, settle, snapshot } from './mobile-harness.mjs'
export function testMobileBehavior(root, framework) {
+ test('HTTP failures reject requests and uploads even without a business error code', async () => {
+ const app = await createMobileHarness(root, framework, {
+ token: 'active-token',
+ confirm: false,
+ })
+ for (const statusCode of [401, 422, 500]) {
+ for (const data of [{ detail: [{ msg: '字段无效' }] }, { code: 200 }]) {
+ app.responses.push({ statusCode, data })
+ await assert.rejects(
+ app.request({ url: '/failure' }),
+ (error) => error.response.statusCode === statusCode
+ )
+ app.uploadResponses.push({ statusCode, data: JSON.stringify(data) })
+ await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' }))
+ }
+ }
+ app.responses.push({
+ statusCode: 422,
+ data: { detail: [{ msg: '字段无效' }] },
+ })
+ await assert.rejects(app.request({ url: '/validation' }), /字段无效/)
+ assert.equal((await app.upload({ url: '/upload', filePath: '/tmp/example' })).code, 200)
+ })
+
+ test('decrypted HTTP failures retain their status and reject', async () => {
+ const app = await createMobileHarness(root, framework, {
+ decryptResponse: async (response) => ({
+ ...response,
+ data: { detail: '解密后的错误' },
+ }),
+ })
+ app.responses.push({ statusCode: 500, data: { encrypted: true } })
+ await assert.rejects(app.request({ url: '/encrypted' }), /解密后的错误/)
+ })
+
test('requests use the current token and timezone while public requests omit credentials', async () => {
- const app = await createMobileHarness(root, framework, { token: 'active-token' })
+ const app = await createMobileHarness(root, framework, {
+ token: 'active-token',
+ })
app.time.setUserTimezone('America/New_York')
assert.equal(
(await app.request({ url: '/private', params: { name: 'a b', page: 2 } })).code,
@@ -27,9 +64,14 @@ export function testMobileBehavior(root, framework) {
})
test('confirmed expired sessions reject the request, clear account state and relaunch login', async () => {
- const app = await createMobileHarness(root, framework, { token: 'expired-token' })
+ const app = await createMobileHarness(root, framework, {
+ token: 'expired-token',
+ })
await app.getInfo()
- app.responses.push({ statusCode: 200, data: { code: 401, msg: 'expired' } })
+ app.responses.push({
+ statusCode: 200,
+ data: { code: 401, msg: 'expired' },
+ })
await assert.rejects(
app.request({ url: '/private' }),
(error) => error.response.data.code === 401
@@ -50,7 +92,10 @@ export function testMobileBehavior(root, framework) {
token: 'expired-token',
confirm: false,
})
- app.responses.push({ statusCode: 200, data: { code: 401, msg: 'expired' } })
+ app.responses.push({
+ statusCode: 200,
+ data: { code: 401, msg: 'expired' },
+ })
await assert.rejects(app.request({ url: '/private' }))
await settle()
assert.equal(app.auth.getToken(), 'expired-token')
@@ -107,14 +152,19 @@ export function testMobileBehavior(root, framework) {
})
test('cold start restores only signed-in timezones and discards invalid preferences', async () => {
- const savedTimezone = { appTimezone: 'Asia/Shanghai', timeZone: 'America/New_York' }
+ const savedTimezone = {
+ appTimezone: 'Asia/Shanghai',
+ timeZone: 'America/New_York',
+ }
const signedIn = await createMobileHarness(root, framework, {
token: 'active-token',
savedTimezone,
})
assert.equal(signedIn.time.getUserTimezone(), 'America/New_York')
assert.equal(signedIn.time.formatBusinessTime('2026-01-01T00:00:00Z'), '2025-12-31 19:00:00')
- const signedOut = await createMobileHarness(root, framework, { savedTimezone })
+ const signedOut = await createMobileHarness(root, framework, {
+ savedTimezone,
+ })
assert.equal(signedOut.time.getUserTimezone(), 'auto')
const invalid = await createMobileHarness(root, framework, {
token: 'active-token',
@@ -125,8 +175,13 @@ export function testMobileBehavior(root, framework) {
})
test('non-authentication failures stay rejected without clearing the account', async () => {
- const app = await createMobileHarness(root, framework, { token: 'active-token' })
- app.responses.push({ statusCode: 429, data: { code: 429, msg: 'rate limited' } })
+ const app = await createMobileHarness(root, framework, {
+ token: 'active-token',
+ })
+ app.responses.push({
+ statusCode: 429,
+ data: { code: 429, msg: 'rate limited' },
+ })
await assert.rejects(
app.request({ url: '/private' }),
(error) => error.response.statusCode === 429
diff --git a/ruoyi-fastapi-test/frontend/mobile-harness.mjs b/ruoyi-fastapi-test/frontend/mobile-harness.mjs
index 5cd42bf92..530676e16 100644
--- a/ruoyi-fastapi-test/frontend/mobile-harness.mjs
+++ b/ruoyi-fastapi-test/frontend/mobile-harness.mjs
@@ -13,15 +13,24 @@ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.strin
export async function createMobileHarness(
root,
framework,
- { token = '', savedTimezone, confirm = true } = {}
+ { token = '', savedTimezone, confirm = true, decryptResponse } = {}
) {
const project = fileURLToPath(root)
const nativeRequire = createRequire(new URL('package.json', root))
const values = new Map([['device-theme', 'dark']])
if (token) values.set('App-Token', token)
if (savedTimezone) values.set('storage_data', { timezone: copy(savedTimezone) })
- const calls = { requests: [], modals: [], toasts: [], navigations: [], login: [], logout: [] }
+ const calls = {
+ requests: [],
+ uploads: [],
+ modals: [],
+ toasts: [],
+ navigations: [],
+ login: [],
+ logout: [],
+ }
const responses = []
+ const uploadResponses = []
const interceptors = new Map()
const uni = {
getStorageSync: (key) => copy(values.get(key)),
@@ -34,9 +43,22 @@ export async function createMobileHarness(
calls.modals.push(options.content)
queueMicrotask(() => options.success({ confirm, cancel: !confirm }))
},
+ uploadFile(options) {
+ calls.uploads.push(copy(options))
+ const response = uploadResponses.shift() || {
+ statusCode: 200,
+ data: '{"code":200}',
+ }
+ queueMicrotask(() =>
+ response.failure ? options.fail(response.failure) : options.success(response)
+ )
+ },
request(options) {
calls.requests.push(copy(options))
- const response = responses.shift() || { statusCode: 200, data: { code: 200, data: 'ok' } }
+ const response = responses.shift() || {
+ statusCode: 200,
+ data: { code: 200, data: 'ok' },
+ }
queueMicrotask(() =>
response.failure ? options.fail(response.failure) : options.success(response)
)
@@ -64,14 +86,19 @@ export async function createMobileHarness(
},
getInfo: async () => ({
appTimezone: 'Asia/Shanghai',
- user: { userId: 7, userName: 'tester', avatar: '', timeZone: 'America/New_York' },
+ user: {
+ userId: 7,
+ userName: 'tester',
+ avatar: '',
+ timeZone: 'America/New_York',
+ },
roles: ['operator'],
permissions: ['system:user:query'],
}),
},
'@/utils/transportCrypto': {
encryptTransportRequest: async (config) => config,
- decryptTransportResponse: async (response) => response,
+ decryptTransportResponse: decryptResponse || (async (response) => response),
decryptTransportErrorResponse: async (error) => error,
shouldRetryTransportWithFreshKey: () => false,
invalidateTransportKeyMeta() {},
@@ -150,6 +177,7 @@ export async function createMobileHarness(
const time = await load('@/utils/time')
const auth = await load('@/utils/auth')
const request = (await load('@/utils/request')).default
+ const upload = (await load('@/utils/upload')).default
await load('@/permission')
return {
state,
@@ -159,6 +187,8 @@ export async function createMobileHarness(
time,
auth,
request,
+ upload,
+ uploadResponses,
values,
responses,
calls,
From a88cad318f11480752de2515115c018f7d9cd42d Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:35:22 +0800
Subject: [PATCH 09/21] =?UTF-8?q?fix:=20=E4=B8=8A=E4=BC=A0=E5=9B=9E?=
=?UTF-8?q?=E8=B0=83=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=E5=92=8C=E5=B9=B3?=
=?UTF-8?q?=E5=8F=B0=E9=94=99=E8=AF=AF=E6=97=B6=E5=8F=AF=E9=9D=A0=E7=BB=93?=
=?UTF-8?q?=E6=9D=9F=20Promise?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../vue2/mobile/src/utils/upload.js | 52 ++++++++++-------
.../vue3/mobile/src/utils/upload.js | 56 +++++++++++--------
.../frontend/mobile-behavior.mjs | 22 ++++++++
3 files changed, 86 insertions(+), 44 deletions(-)
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
index 76b916b69..77f51c772 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
@@ -29,29 +29,37 @@ const upload = (config) => {
header: config.header,
formData: config.formData,
success: (res) => {
- let result = JSON.parse(res.data)
- const { code, msg } = getResponseStatus(res, result)
- if (code === 200) {
- resolve(result)
- } else if (code == 401) {
- showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
- if (res.confirm) {
- store.dispatch('LogOut').then((res) => {
- uni.reLaunch({ url: '/pages/login/login' })
- })
- }
- })
- reject('无效的会话,或者会话已过期,请重新登录。')
- } else if (code === 500) {
- toast(msg)
- reject('500')
- } else if (code !== 200) {
- toast(msg)
- reject(code)
+ try {
+ const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
+ if (!result || typeof result !== 'object') {
+ throw new Error('上传响应格式不正确')
+ }
+ const { code, msg } = getResponseStatus(res, result)
+ if (code === 200) {
+ resolve(result)
+ } else if (code == 401) {
+ showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
+ if (res.confirm) {
+ store.dispatch('LogOut').then((res) => {
+ uni.reLaunch({ url: '/pages/login/login' })
+ })
+ }
+ })
+ reject('无效的会话,或者会话已过期,请重新登录。')
+ } else if (code === 500) {
+ toast(msg)
+ reject('500')
+ } else if (code !== 200) {
+ toast(msg)
+ reject(code)
+ }
+ } catch (error) {
+ toast('上传响应格式不正确')
+ reject(error)
}
},
fail: (error) => {
- let { message } = error
+ let message = String(error?.message || error?.errMsg || '上传失败')
if (message == 'Network Error') {
message = '后端接口连接异常'
} else if (message.includes('timeout')) {
@@ -60,7 +68,9 @@ const upload = (config) => {
message = '系统接口' + message.substr(message.length - 3) + '异常'
}
toast(message)
- reject(error)
+ const uploadError = error instanceof Error ? error : new Error(message)
+ if (uploadError !== error) uploadError.cause = error
+ reject(uploadError)
},
})
})
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
index 01bc7c1c8..46ea048db 100644
--- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
@@ -29,31 +29,39 @@ export default function upload(config) {
header: config.header,
formData: config.formData,
success: (res) => {
- let result = JSON.parse(res.data)
- const { code, msg } = getResponseStatus(res, result)
- if (code === 200) {
- resolve(result)
- } else if (code == 401) {
- showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
- if (res.confirm) {
- useUserStore()
- .logOut()
- .then((res) => {
- uni.reLaunch({ url: '/pages/login/login' })
- })
- }
- })
- reject('无效的会话,或者会话已过期,请重新登录。')
- } else if (code === 500) {
- toast(msg)
- reject('500')
- } else if (code !== 200) {
- toast(msg)
- reject(code)
+ try {
+ const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
+ if (!result || typeof result !== 'object') {
+ throw new Error('上传响应格式不正确')
+ }
+ const { code, msg } = getResponseStatus(res, result)
+ if (code === 200) {
+ resolve(result)
+ } else if (code == 401) {
+ showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
+ if (res.confirm) {
+ useUserStore()
+ .logOut()
+ .then((res) => {
+ uni.reLaunch({ url: '/pages/login/login' })
+ })
+ }
+ })
+ reject('无效的会话,或者会话已过期,请重新登录。')
+ } else if (code === 500) {
+ toast(msg)
+ reject('500')
+ } else if (code !== 200) {
+ toast(msg)
+ reject(code)
+ }
+ } catch (error) {
+ toast('上传响应格式不正确')
+ reject(error)
}
},
fail: (error) => {
- let { message } = error
+ let message = String(error?.message || error?.errMsg || '上传失败')
if (message == 'Network Error') {
message = '后端接口连接异常'
} else if (message.includes('timeout')) {
@@ -62,7 +70,9 @@ export default function upload(config) {
message = '系统接口' + message.substr(message.length - 3) + '异常'
}
toast(message)
- reject(error)
+ const uploadError = error instanceof Error ? error : new Error(message)
+ if (uploadError !== error) uploadError.cause = error
+ reject(uploadError)
},
})
})
diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
index 240f303f0..26d571e48 100644
--- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
+++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
@@ -38,6 +38,28 @@ export function testMobileBehavior(root, framework) {
await assert.rejects(app.request({ url: '/encrypted' }), /解密后的错误/)
})
+ test('upload parse failures and platform errors always settle the promise', async () => {
+ const app = await createMobileHarness(root, framework)
+ for (const data of ['bad gateway', '', 'null']) {
+ app.uploadResponses.push({ statusCode: 502, data })
+ await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' }))
+ }
+ for (const failure of [
+ { errMsg: 'uploadFile:fail timeout' },
+ { errMsg: 'uploadFile:fail network' },
+ {},
+ ]) {
+ app.uploadResponses.push({ failure })
+ await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' }), (error) =>
+ Boolean(error.message)
+ )
+ }
+ assert.equal(
+ app.calls.toasts.some((entry) => entry.title === '系统接口请求超时'),
+ true
+ )
+ })
+
test('requests use the current token and timezone while public requests omit credentials', async () => {
const app = await createMobileHarness(root, framework, {
token: 'active-token',
From b44fe77757ee1ddd8fba5c8a191331f6b6b346d8 Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:35:31 +0800
Subject: [PATCH 10/21] =?UTF-8?q?fix:=20=E4=B8=8A=E4=BC=A0=E4=BC=9A?=
=?UTF-8?q?=E8=AF=9D=E5=A4=B1=E6=95=88=E5=90=8E=E8=B7=B3=E8=BD=AC=E5=88=B0?=
=?UTF-8?q?=E5=B7=B2=E6=B3=A8=E5=86=8C=E7=9A=84=E7=99=BB=E5=BD=95=E9=A1=B5?=
=?UTF-8?q?=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js | 2 +-
ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js | 2 +-
ruoyi-fastapi-test/frontend/mobile-behavior.mjs | 8 ++++++++
3 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
index 77f51c772..b3da170df 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
@@ -41,7 +41,7 @@ const upload = (config) => {
showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
if (res.confirm) {
store.dispatch('LogOut').then((res) => {
- uni.reLaunch({ url: '/pages/login/login' })
+ uni.reLaunch({ url: '/pages/login' })
})
}
})
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
index 46ea048db..235d898ad 100644
--- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
@@ -43,7 +43,7 @@ export default function upload(config) {
useUserStore()
.logOut()
.then((res) => {
- uni.reLaunch({ url: '/pages/login/login' })
+ uni.reLaunch({ url: '/pages/login' })
})
}
})
diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
index 26d571e48..be1a3e282 100644
--- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
+++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
@@ -60,6 +60,14 @@ export function testMobileBehavior(root, framework) {
)
})
+ test('expired upload sessions navigate to the registered login page', async () => {
+ const app = await createMobileHarness(root, framework, { token: 'expired-token' })
+ app.uploadResponses.push({ statusCode: 200, data: '{"code":401}' })
+ await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' }))
+ await settle()
+ assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }])
+ })
+
test('requests use the current token and timezone while public requests omit credentials', async () => {
const app = await createMobileHarness(root, framework, {
token: 'active-token',
From 3be52bc4124d03a1b720f929805f30be218928c0 Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:43:11 +0800
Subject: [PATCH 11/21] =?UTF-8?q?fix:=20=E8=BF=9C=E7=AB=AF=E9=80=80?=
=?UTF-8?q?=E5=87=BA=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=BB=8D=E6=B8=85=E7=90=86?=
=?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E6=9C=AC=E5=9C=B0=E4=BC=9A=E8=AF=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../vue2/mobile/src/store/modules/user.js | 34 +++++++++----------
.../vue3/mobile/src/store/modules/user.js | 34 +++++++++----------
.../frontend/mobile-behavior.mjs | 22 ++++++++++++
.../frontend/mobile-harness.mjs | 3 +-
4 files changed, 58 insertions(+), 35 deletions(-)
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js b/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js
index dfdaf162d..205ee547a 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js
@@ -110,23 +110,23 @@ const user = {
})
},
- // 退出系统
- LogOut({ commit, state }) {
- return new Promise((resolve, reject) => {
- logout(state.token)
- .then(() => {
- commit('SET_TOKEN', '')
- commit('SET_TIMEZONE', 'auto')
- commit('SET_ROLES', [])
- commit('SET_PERMISSIONS', [])
- removeToken()
- storage.clean()
- resolve()
- })
- .catch((error) => {
- reject(error)
- })
- })
+ // 退出系统:远端撤销失败也必须清理本地会话,保证可以重新登录。
+ async LogOut({ commit, state }) {
+ try {
+ await logout(state.token)
+ } catch {
+ // 远端异常由请求层提示,本地退出不依赖远端撤销结果。
+ } finally {
+ commit('SET_TOKEN', '')
+ commit('SET_ID', '')
+ commit('SET_NAME', '')
+ commit('SET_AVATAR', '')
+ commit('SET_TIMEZONE', 'auto')
+ commit('SET_ROLES', [])
+ commit('SET_PERMISSIONS', [])
+ removeToken()
+ storage.clean()
+ }
},
},
}
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/store/modules/user.js b/ruoyi-fastapi-frontend/vue3/mobile/src/store/modules/user.js
index 17b4e8657..8ccc71af2 100644
--- a/ruoyi-fastapi-frontend/vue3/mobile/src/store/modules/user.js
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/store/modules/user.js
@@ -116,23 +116,23 @@ export const useUserStore = defineStore('user', () => {
})
}
- // 退出系统
- const logOutAction = () => {
- return new Promise((resolve, reject) => {
- logout(token.value)
- .then(() => {
- SET_TOKEN('')
- applyTimezone('auto')
- SET_ROLES([])
- SET_PERMISSIONS([])
- removeToken()
- storage.clean()
- resolve()
- })
- .catch((error) => {
- reject(error)
- })
- })
+ // 退出系统:远端撤销失败也必须清理本地会话,保证可以重新登录。
+ const logOutAction = async () => {
+ try {
+ await logout(token.value)
+ } catch {
+ // 远端异常由请求层提示,本地退出不依赖远端撤销结果。
+ } finally {
+ SET_TOKEN('')
+ SET_ID('')
+ SET_NAME('')
+ SET_AVATAR('')
+ applyTimezone('auto')
+ SET_ROLES([])
+ SET_PERMISSIONS([])
+ removeToken()
+ storage.clean()
+ }
}
return {
diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
index be1a3e282..db89abd6a 100644
--- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
+++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
@@ -68,6 +68,28 @@ export function testMobileBehavior(root, framework) {
assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }])
})
+ test('remote logout failures still clear all account state and allow login navigation', async () => {
+ for (const message of ['server error', 'timeout', 'Network Error']) {
+ const app = await createMobileHarness(root, framework, {
+ token: 'expired-token',
+ logoutError: new Error(message),
+ })
+ await app.getInfo()
+ app.responses.push({ statusCode: 401, data: {} })
+ await assert.rejects(app.request({ url: '/private' }))
+ await settle()
+ assert.equal(app.auth.getToken(), undefined)
+ assert.equal(app.state.token, '')
+ assert.equal(app.state.id, '')
+ assert.equal(app.state.name, '')
+ assert.equal(app.state.avatar, '')
+ assert.deepEqual(snapshot(app.state.roles), [])
+ assert.deepEqual(snapshot(app.state.permissions), [])
+ assert.equal(app.values.has('storage_data'), false)
+ assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }])
+ }
+ })
+
test('requests use the current token and timezone while public requests omit credentials', async () => {
const app = await createMobileHarness(root, framework, {
token: 'active-token',
diff --git a/ruoyi-fastapi-test/frontend/mobile-harness.mjs b/ruoyi-fastapi-test/frontend/mobile-harness.mjs
index 530676e16..569cd2fa1 100644
--- a/ruoyi-fastapi-test/frontend/mobile-harness.mjs
+++ b/ruoyi-fastapi-test/frontend/mobile-harness.mjs
@@ -13,7 +13,7 @@ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.strin
export async function createMobileHarness(
root,
framework,
- { token = '', savedTimezone, confirm = true, decryptResponse } = {}
+ { token = '', savedTimezone, confirm = true, decryptResponse, logoutError } = {}
) {
const project = fileURLToPath(root)
const nativeRequire = createRequire(new URL('package.json', root))
@@ -83,6 +83,7 @@ export async function createMobileHarness(
},
logout: async (value) => {
calls.logout.push(value)
+ if (logoutError) throw logoutError
},
getInfo: async () => ({
appTimezone: 'Asia/Shanghai',
From c9d7fb4ed036cd6879a1187fae830c9628ea917d Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:43:41 +0800
Subject: [PATCH 12/21] =?UTF-8?q?fix:=20=E8=AF=B7=E6=B1=82=E4=B8=8E?=
=?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=85=B1=E7=94=A8=E7=A7=BB=E5=8A=A8=E7=AB=AF?=
=?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=A4=B1=E6=95=88=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../vue2/mobile/src/utils/common.js | 1 +
.../vue2/mobile/src/utils/request.js | 17 ++++-----
.../vue2/mobile/src/utils/session.js | 29 +++++++++++++++
.../vue2/mobile/src/utils/upload.js | 17 ++++-----
.../vue3/mobile/src/utils/common.js | 1 +
.../vue3/mobile/src/utils/request.js | 19 ++++------
.../vue3/mobile/src/utils/session.js | 29 +++++++++++++++
.../vue3/mobile/src/utils/upload.js | 19 ++++------
.../frontend/mobile-behavior.mjs | 35 +++++++++++++++++++
.../frontend/mobile-harness.mjs | 14 ++++++--
10 files changed, 131 insertions(+), 50 deletions(-)
create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/session.js
create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/session.js
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js
index 194d1cb66..cb99fbf6b 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js
@@ -23,6 +23,7 @@ export function showConfirm(content) {
success: function (res) {
resolve(res)
},
+ fail: reject,
})
})
}
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js
index 0066b075a..24203266f 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js
@@ -1,9 +1,9 @@
+import { handleSessionExpired } from '@/utils/session'
import { getDisplayTimezone } from '@/utils/time'
-import store from '@/store'
import config from '@/config'
import { getToken } from '@/utils/auth'
import { getResponseStatus } from '@/utils/response'
-import { toast, showConfirm, tansParams } from '@/utils/common'
+import { toast, tansParams } from '@/utils/common'
import {
decryptTransportErrorResponse,
decryptTransportResponse,
@@ -28,8 +28,9 @@ const request = async (config) => {
config.header = config.header || {}
config.headers = config.headers || {}
config.header['X-Timezone'] = config.header['X-Timezone'] || getDisplayTimezone()
- if (getToken() && !isToken) {
- config.header['Authorization'] = 'Bearer ' + getToken()
+ const token = getToken()
+ if (token && !isToken) {
+ config.header['Authorization'] = 'Bearer ' + token
}
try {
@@ -68,13 +69,7 @@ const request = async (config) => {
const { code, msg } = getResponseStatus(res, res.data)
if (code === 401) {
- showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
- if (res.confirm) {
- store.dispatch('LogOut').then(() => {
- uni.reLaunch({ url: '/pages/login' })
- })
- }
- })
+ handleSessionExpired(isToken ? null : token)
const error = new Error('无效的会话,或者会话已过期,请重新登录。')
error.response = res
reject(error)
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/session.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/session.js
new file mode 100644
index 000000000..6dd1959b0
--- /dev/null
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/session.js
@@ -0,0 +1,29 @@
+import store from '@/store'
+import { getToken } from '@/utils/auth'
+import { showConfirm } from '@/utils/common'
+
+let sessionExpiredPromise = null
+
+/**
+ * 合并普通请求与上传的会话失效处理,避免重复确认、退出和跳转。
+ * @param {string} token 发起请求时使用的令牌
+ * @returns {Promise} 是否已确认退出
+ */
+export function handleSessionExpired(token) {
+ // 忽略旧会话的迟到响应,避免影响已经重新登录的账号。
+ if (!token || token !== getToken()) return Promise.resolve(false)
+ if (sessionExpiredPromise) return sessionExpiredPromise
+ sessionExpiredPromise = Promise.resolve()
+ .then(() => showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?'))
+ .then(async (result) => {
+ if (!result.confirm || token !== getToken()) return false
+ await store.dispatch('LogOut')
+ uni.reLaunch({ url: '/pages/login' })
+ return true
+ })
+ .catch(() => false)
+ .finally(() => {
+ sessionExpiredPromise = null
+ })
+ return sessionExpiredPromise
+}
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
index b3da170df..e56e0a499 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js
@@ -1,8 +1,8 @@
-import store from '@/store'
+import { handleSessionExpired } from '@/utils/session'
import config from '@/config'
import { getToken } from '@/utils/auth'
import { getResponseStatus } from '@/utils/response'
-import { toast, showConfirm, tansParams } from '@/utils/common'
+import { toast, tansParams } from '@/utils/common'
let timeout = 10000
const baseUrl = config.baseUrl
@@ -11,8 +11,9 @@ const upload = (config) => {
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false
config.header = config.header || {}
- if (getToken() && !isToken) {
- config.header['Authorization'] = 'Bearer ' + getToken()
+ const token = getToken()
+ if (token && !isToken) {
+ config.header['Authorization'] = 'Bearer ' + token
}
// get请求映射params参数
if (config.params) {
@@ -38,13 +39,7 @@ const upload = (config) => {
if (code === 200) {
resolve(result)
} else if (code == 401) {
- showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
- if (res.confirm) {
- store.dispatch('LogOut').then((res) => {
- uni.reLaunch({ url: '/pages/login' })
- })
- }
- })
+ handleSessionExpired(isToken ? null : token)
reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code === 500) {
toast(msg)
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js
index 194d1cb66..cb99fbf6b 100644
--- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js
@@ -23,6 +23,7 @@ export function showConfirm(content) {
success: function (res) {
resolve(res)
},
+ fail: reject,
})
})
}
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js
index 2065a5fe8..ff5c510f2 100644
--- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js
@@ -1,9 +1,9 @@
+import { handleSessionExpired } from '@/utils/session'
import config from '@/config'
import { getToken } from '@/utils/auth'
import { getDisplayTimezone } from '@/utils/time'
import { getResponseStatus } from '@/utils/response'
-import { useUserStore } from '@/store/modules/user'
-import { toast, showConfirm, tansParams } from '@/utils/common'
+import { toast, tansParams } from '@/utils/common'
import {
decryptTransportErrorResponse,
decryptTransportResponse,
@@ -22,8 +22,9 @@ const request = async (config) => {
config.header = config.header || {}
config.headers = config.headers || {}
config.header['X-Timezone'] = config.header['X-Timezone'] || getDisplayTimezone()
- if (getToken() && !isToken) {
- config.header['Authorization'] = 'Bearer ' + getToken()
+ const token = getToken()
+ if (token && !isToken) {
+ config.header['Authorization'] = 'Bearer ' + token
}
try {
@@ -59,15 +60,7 @@ const request = async (config) => {
const { code, msg } = getResponseStatus(res, res.data)
if (code === 401) {
- showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
- if (res.confirm) {
- useUserStore()
- .logOut()
- .then(() => {
- uni.reLaunch({ url: '/pages/login' })
- })
- }
- })
+ handleSessionExpired(isToken ? null : token)
const error = new Error('无效的会话,或者会话已过期,请重新登录。')
error.response = res
reject(error)
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/session.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/session.js
new file mode 100644
index 000000000..1c6344ba1
--- /dev/null
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/session.js
@@ -0,0 +1,29 @@
+import { useUserStore } from '@/store/modules/user'
+import { getToken } from '@/utils/auth'
+import { showConfirm } from '@/utils/common'
+
+let sessionExpiredPromise = null
+
+/**
+ * 合并普通请求与上传的会话失效处理,避免重复确认、退出和跳转。
+ * @param {string} token 发起请求时使用的令牌
+ * @returns {Promise} 是否已确认退出
+ */
+export function handleSessionExpired(token) {
+ // 忽略旧会话的迟到响应,避免影响已经重新登录的账号。
+ if (!token || token !== getToken()) return Promise.resolve(false)
+ if (sessionExpiredPromise) return sessionExpiredPromise
+ sessionExpiredPromise = Promise.resolve()
+ .then(() => showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?'))
+ .then(async (result) => {
+ if (!result.confirm || token !== getToken()) return false
+ await useUserStore().logOut()
+ uni.reLaunch({ url: '/pages/login' })
+ return true
+ })
+ .catch(() => false)
+ .finally(() => {
+ sessionExpiredPromise = null
+ })
+ return sessionExpiredPromise
+}
diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
index 235d898ad..db67d4c74 100644
--- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
+++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js
@@ -1,8 +1,8 @@
-import { useUserStore } from '@/store'
+import { handleSessionExpired } from '@/utils/session'
import config from '@/config'
import { getToken } from '@/utils/auth'
import { getResponseStatus } from '@/utils/response'
-import { toast, showConfirm, tansParams } from '@/utils/common'
+import { toast, tansParams } from '@/utils/common'
let timeout = 10000
const baseUrl = config.baseUrl
@@ -11,8 +11,9 @@ export default function upload(config) {
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false
config.header = config.header || {}
- if (getToken() && !isToken) {
- config.header['Authorization'] = 'Bearer ' + getToken()
+ const token = getToken()
+ if (token && !isToken) {
+ config.header['Authorization'] = 'Bearer ' + token
}
// get请求映射params参数
if (config.params) {
@@ -38,15 +39,7 @@ export default function upload(config) {
if (code === 200) {
resolve(result)
} else if (code == 401) {
- showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => {
- if (res.confirm) {
- useUserStore()
- .logOut()
- .then((res) => {
- uni.reLaunch({ url: '/pages/login' })
- })
- }
- })
+ handleSessionExpired(isToken ? null : token)
reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code === 500) {
toast(msg)
diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
index db89abd6a..1483367fc 100644
--- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
+++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs
@@ -90,6 +90,41 @@ export function testMobileBehavior(root, framework) {
}
})
+ test('concurrent request and upload failures share one session recovery', async () => {
+ const app = await createMobileHarness(root, framework, { token: 'expired-token' })
+ app.responses.push(...Array.from({ length: 3 }, () => ({ statusCode: 401, data: {} })))
+ app.uploadResponses.push(...Array.from({ length: 3 }, () => ({ statusCode: 401, data: '{}' })))
+ await Promise.all(
+ Array.from({ length: 3 }, () => [
+ assert.rejects(app.request({ url: '/private' })),
+ assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' })),
+ ]).flat()
+ )
+ await settle()
+ assert.equal(app.calls.modals.length, 1)
+ assert.deepEqual(app.calls.logout, ['expired-token'])
+ assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }])
+ })
+
+ test('cancelled or failed dialogs release session recovery for later requests', async () => {
+ for (const first of [{ confirm: false }, { failure: new Error('modal unavailable') }]) {
+ const app = await createMobileHarness(root, framework, {
+ token: 'expired-token',
+ confirmResponses: [first, { confirm: true }],
+ })
+ app.responses.push({ statusCode: 401, data: {} })
+ await assert.rejects(app.request({ url: '/private' }))
+ await settle()
+ assert.equal(app.auth.getToken(), 'expired-token')
+ app.uploadResponses.push({ statusCode: 401, data: '{}' })
+ await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' }))
+ await settle()
+ assert.equal(app.calls.modals.length, 2)
+ assert.deepEqual(app.calls.logout, ['expired-token'])
+ assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }])
+ }
+ })
+
test('requests use the current token and timezone while public requests omit credentials', async () => {
const app = await createMobileHarness(root, framework, {
token: 'active-token',
diff --git a/ruoyi-fastapi-test/frontend/mobile-harness.mjs b/ruoyi-fastapi-test/frontend/mobile-harness.mjs
index 569cd2fa1..7a646f01f 100644
--- a/ruoyi-fastapi-test/frontend/mobile-harness.mjs
+++ b/ruoyi-fastapi-test/frontend/mobile-harness.mjs
@@ -13,7 +13,14 @@ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.strin
export async function createMobileHarness(
root,
framework,
- { token = '', savedTimezone, confirm = true, decryptResponse, logoutError } = {}
+ {
+ token = '',
+ savedTimezone,
+ confirm = true,
+ decryptResponse,
+ logoutError,
+ confirmResponses = [],
+ } = {}
) {
const project = fileURLToPath(root)
const nativeRequire = createRequire(new URL('package.json', root))
@@ -41,7 +48,10 @@ export async function createMobileHarness(
showToast: (options) => calls.toasts.push(copy(options)),
showModal(options) {
calls.modals.push(options.content)
- queueMicrotask(() => options.success({ confirm, cancel: !confirm }))
+ const result = confirmResponses.shift() || { confirm, cancel: !confirm }
+ queueMicrotask(() =>
+ result.failure ? options.fail(result.failure) : options.success(result)
+ )
},
uploadFile(options) {
calls.uploads.push(copy(options))
From 754c0e3cef1d7649707eb50b4a2d42f61c8da53c Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:45:30 +0800
Subject: [PATCH 13/21] =?UTF-8?q?fix:=20CLI=20=E4=BF=9D=E7=95=99=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E6=89=A7=E8=A1=8C=E7=BC=96=E5=8F=B7=E4=B8=8E=E8=B0=83?=
=?UTF-8?q?=E5=BA=A6=E5=90=8C=E6=AD=A5=E7=BB=93=E6=9E=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../cli/runtime/job/service.py | 4 +-
.../tests/cli/runtime/test_job.py | 42 +++++++++++++++++++
2 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/ruoyi-fastapi-backend/cli/runtime/job/service.py b/ruoyi-fastapi-backend/cli/runtime/job/service.py
index 6f6c38e4b..705e46cd8 100644
--- a/ruoyi-fastapi-backend/cli/runtime/job/service.py
+++ b/ruoyi-fastapi-backend/cli/runtime/job/service.py
@@ -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]:
diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py b/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py
index 6da16e82c..2f3b3c6f2 100644
--- a/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py
+++ b/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py
@@ -1,8 +1,14 @@
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
import pytest
+from cli.output import OutputRenderer
from cli.runtime.job import JobRuntimeService
from cli.runtime.job.gateway import JobInfrastructureGateway
from cli.runtime.job.support import JobDomainSupport, JobSchedulerSupport
+from common.vo import CrudResponseModel
MISSING_JOB_ID = 99
@@ -172,3 +178,39 @@ def _fake_get_job_service() -> FakeJobService:
assert payload['ok'] is False
assert payload['jobId'] == MISSING_JOB_ID
assert payload['message'] == f'定时任务不存在:{MISSING_JOB_ID}'
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('operation', ['run-once', 'pause', 'resume'])
+async def test_job_operation_retains_execution_and_sync_results(
+ operation: str, capsys: pytest.CaptureFixture[str]
+) -> None:
+ result = (
+ {'executionId': 'execution-123', 'status': 'pending'}
+ if operation == 'run-once'
+ else {'saved': True, 'synced': False, 'jobs': [{'jobId': 7, 'syncStatus': 'pending'}]}
+ )
+ response = CrudResponseModel(is_success=True, message='已提交,等待调度器处理', result=result)
+ job_service = SimpleNamespace(
+ execute_job_once_services=AsyncMock(return_value=response),
+ edit_job_services=AsyncMock(return_value=response),
+ )
+ gateway = MagicMock()
+ gateway.get_async_session_local.return_value = MagicMock(return_value=AsyncMock())
+ gateway.get_redis_util.return_value.create_redis_pool = AsyncMock()
+ gateway.get_scheduler_manager.return_value.init_system_scheduler = AsyncMock()
+ gateway.get_job_service.return_value = job_service
+ scheduler_support = SimpleNamespace(close_scheduler_context=AsyncMock())
+ service = JobRuntimeService(infrastructure_gateway=gateway, scheduler_support=scheduler_support)
+ payload = await service.run_with_scheduler_context(operation, 7)
+ assert payload['result'] == result
+ assert payload['message'] == response.message
+ OutputRenderer().emit_output(payload, 'json')
+ assert json.loads(capsys.readouterr().out)['result'] == result
+ text = OutputRenderer().render_text_output(payload)
+ if operation == 'run-once':
+ assert 'execution-123' in text
+ assert 'pending' in text
+ else:
+ assert 'saved' in text and 'synced' in text
+ scheduler_support.close_scheduler_context.assert_awaited_once()
From ab4219f108e7debb2fc515c324676086eb0ed153 Mon Sep 17 00:00:00 2001
From: insistence <3055204202@qq.com>
Date: Wed, 21 Oct 2026 16:48:51 +0800
Subject: [PATCH 14/21] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E7=A7=BB?=
=?UTF-8?q?=E5=8A=A8=20WebView=20=E5=8D=8F=E8=AE=AE=E6=A0=A1=E9=AA=8C?=
=?UTF-8?q?=E5=B9=B6=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D=E8=A7=A3=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../mobile/src/pages/common/webview/index.jsx | 5 ++--
.../react/mobile/src/utils/webview.js | 22 ++++++++++++++++++
.../react/mobile/tests/route-params.test.mjs | 18 +++++++++++++++
.../mobile/src/pages/common/webview/index.vue | 3 ++-
.../vue2/mobile/src/utils/webview.js | 22 ++++++++++++++++++
.../mobile/src/pages/common/webview/index.vue | 3 ++-
.../vue3/mobile/src/utils/webview.js | 22 ++++++++++++++++++
.../frontend/mobile-behavior.mjs | 23 +++++++++++++++++++
.../frontend/mobile-harness.mjs | 2 ++
9 files changed, 116 insertions(+), 4 deletions(-)
create mode 100644 ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js
create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/webview.js
create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/webview.js
diff --git a/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx b/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx
index 273fc47a1..8c83d7143 100644
--- a/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx
+++ b/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx
@@ -1,13 +1,14 @@
import React, { useState } from 'react'
import Taro, { useLoad, useDidShow } from '@tarojs/taro'
import { decodeRouteParam } from '@/utils/routeParams'
+import { resolveWebviewUrl } from '@/utils/webview'
import { WebView, View, Text } from '@tarojs/components'
export default function Browser() {
const [params, setParams] = useState({})
useLoad((options) => {
setParams({
title: decodeRouteParam(options.title),
- url: decodeRouteParam(options.url),
+ url: resolveWebviewUrl(options.url),
})
})
useDidShow(() => {
@@ -16,7 +17,7 @@ export default function Browser() {
React.useEffect(() => {
if (params.title) Taro.setNavigationBarTitle({ title: params.title })
}, [params.title])
- return /^https?:\/\//i.test(params.url || '') ? (
+ return params.url ? (
) : (
diff --git a/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js b/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js
new file mode 100644
index 000000000..2e2419a6e
--- /dev/null
+++ b/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js
@@ -0,0 +1,22 @@
+/**
+ * 解析 WebView 路由地址,保留 HTTP(S) 外部网页能力。
+ * 仅对整体编码的 URL 解码一次,避免改写已解码地址中的嵌套查询参数。
+ * @param {string} value 路由传入的网页地址
+ * @returns {string} 合法的 HTTP(S) 地址,无效时返回空字符串
+ */
+export function resolveWebviewUrl(value) {
+ if (typeof value !== 'string') return ''
+ let url = value.trim()
+ if (/^https?%3a%2f%2f/i.test(url)) {
+ try {
+ url = decodeURIComponent(url)
+ } catch {
+ return ''
+ }
+ }
+ // 各移动平台的 URL API 支持不同,使用保守的协议与主机检查。
+ if (!/^https?:\/\/[^/?#\s]+(?:[/?#]|$)/i.test(url) || /[\u0000-\u0020\u007f\\]/.test(url)) {
+ return ''
+ }
+ return url
+}
diff --git a/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs b/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs
index 8c99b3aa5..0899578f6 100644
--- a/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs
+++ b/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs
@@ -17,3 +17,21 @@ test('missing and malformed query values do not crash the page', () => {
assert.equal(decodeRouteParam('说明 100%'), '说明 100%')
assert.equal(decodeRouteParam('%E4%B8'), '%E4%B8')
})
+
+test('webview keeps nested query encoding and rejects non-HTTP protocols', async () => {
+ const { resolveWebviewUrl } = await import('../src/utils/webview.js')
+ const url =
+ 'https://example.com/中文?q=100%25&next=https%3A%2F%2Fother.example%2F%3Fa%3D1%26b%3D2'
+ assert.equal(resolveWebviewUrl(url), url)
+ assert.equal(resolveWebviewUrl(encodeURIComponent(url)), url)
+ for (const invalid of [
+ 'javascript:alert(1)',
+ 'data:text/html,x',
+ 'file:///private',
+ '//example.com',
+ 'https://',
+ '%E4%B8',
+ ]) {
+ assert.equal(resolveWebviewUrl(invalid), '')
+ }
+})
diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue
index 1e66ffa7c..cb5352c2c 100644
--- a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue
+++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue
@@ -8,6 +8,7 @@