From 892ba5886da1238a7c5d805c8993003f6b15af5d Mon Sep 17 00:00:00 2001 From: lzq <357099073@qq.com> Date: Mon, 19 Oct 2026 17:34:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/reimburse.js | 14 +- backend/src/router/dynamicRoutes.js | 11 +- backend/src/router/menuDefaults.js | 48 +++ backend/src/stores/menu.js | 27 +- backend/src/utils/request.js | 120 +++++--- .../apps/oa/reimburse/components/detail.vue | 167 ++++++++-- .../apps/oa/reimburse/components/edit.vue | 4 +- backend/src/views/apps/oa/reimburse/index.vue | 86 +++++- go/controllers/app_oa.go | 67 ++-- go/controllers/app_oa_expense.go | 2 +- go/controllers/backend_menu.go | 4 + go/controllers/backend_reimburse.go | 287 ++++++++++++++---- go/controllers/backend_reimburse_test.go | 27 ++ go/controllers/reimburse_approval_core.go | 2 +- go/main.go | 4 + go/middleware/security_probe.go | 283 +++++++++++++++++ go/middleware/security_probe_test.go | 92 ++++++ go/routers/backend/backend.go | 1 + go/routers/router.go | 6 + go/routers/spa.go | 4 +- 20 files changed, 1078 insertions(+), 178 deletions(-) create mode 100644 backend/src/router/menuDefaults.js create mode 100644 go/controllers/backend_reimburse_test.go create mode 100644 go/middleware/security_probe.go create mode 100644 go/middleware/security_probe_test.go diff --git a/backend/src/api/reimburse.js b/backend/src/api/reimburse.js index 120d7e3..8ac4c74 100644 --- a/backend/src/api/reimburse.js +++ b/backend/src/api/reimburse.js @@ -45,10 +45,20 @@ export function deleteReimbursement(id) { }) } -export function submitReimbursement(id) { +export function submitReimbursement(id, comment = '') { return request({ url: `/backend/reimbursements/${id}/submit`, - method: 'post' + method: 'post', + data: { comment } + }) +} + +// 撤回审批中的报销单:撤掉运行中的审批实例并回到草稿,撤回后可继续编辑、再次提交 +export function withdrawReimbursement(id, comment = '') { + return request({ + url: `/backend/reimbursements/${id}/withdraw`, + method: 'post', + data: { comment } }) } diff --git a/backend/src/router/dynamicRoutes.js b/backend/src/router/dynamicRoutes.js index 61a218a..275235a 100644 --- a/backend/src/router/dynamicRoutes.js +++ b/backend/src/router/dynamicRoutes.js @@ -1,4 +1,5 @@ import { createComponentLoader } from '@/utils/pathResolver'; +import { pickDefaultChild } from './menuDefaults'; function computeFullPath(menuPath, parentPath) { if (!menuPath) return parentPath || ''; @@ -105,7 +106,7 @@ function nestedMenuToRoute(menu, layoutAbsPath) { component: () => import('@/views/layouts/EmptyLayout.vue'), children: convertNestedMenuChildren(menu.children, childAbs), }; - const firstChild = menu.children[0]; + const firstChild = pickDefaultChild(menu.children); if (firstChild && firstChild.path) { const firstAbs = computeFullPath(firstChild.path, childAbs); const firstRel = toRelativeChildPath(childAbs, firstAbs); @@ -193,9 +194,11 @@ export function convertMenusToRoutes(menus, parentPath = '') { } else if (hasChildren) { route.component = () => import('@/views/layouts/EmptyLayout.vue'); route.children = convertMenusToRoutes(menu.children, fullPath); - const defaultChild = menu.children.find((child) => - child.path === '/apps/cms/analytics/content' || child.path === 'analytics/content' - ) || menu.children[0]; + // CMS 内容统计是内置默认页,保持原行为;其余模块统一优先「工作台」 + const defaultChild = + menu.children.find( + (child) => child.path === '/apps/cms/analytics/content' || child.path === 'analytics/content' + ) || pickDefaultChild(menu.children); if (defaultChild && defaultChild.path) { const childFullPath = defaultChild.path.startsWith('/') ? defaultChild.path diff --git a/backend/src/router/menuDefaults.js b/backend/src/router/menuDefaults.js new file mode 100644 index 0000000..9eed2f4 --- /dev/null +++ b/backend/src/router/menuDefaults.js @@ -0,0 +1,48 @@ +/** + * 菜单默认落地页规则。 + * + * 模块根菜单本身通常是目录,点击模块(如首页的 ERP 卡片)后由这里的规则 + * 决定跳到哪个子菜单。规则按「工作台优先」收敛,避免依赖后端返回顺序 + * 而落到某个先创建的页面(例如 ERP 的采购订单)。 + */ + +/** 工作台/仪表盘入口的识别规则(标题、菜单路径、组件路径) */ +const WORKBENCH_TITLE_RE = /工作台|仪表盘/; +const WORKBENCH_PATH_RE = /(^|\/)(dashboard|workbench)(\/|\.|$)/i; + +export function isWorkbenchMenu(menu) { + if (!menu) return false; + return ( + WORKBENCH_TITLE_RE.test(String(menu.title || '')) || + WORKBENCH_PATH_RE.test(String(menu.path || '')) || + WORKBENCH_PATH_RE.test(String(menu.component_path || '')) + ); +} + +/** 按 sort(其次 id)升序:后端菜单按 ID 返回,与侧栏按 sort 展示的顺序可能不一致 */ +export function sortByMenuOrder(menus) { + return [...menus].sort((a, b) => { + const sortA = Number(a.sort) || 0; + const sortB = Number(b.sort) || 0; + if (sortA !== sortB) return sortA - sortB; + return (Number(a.id) || 0) - (Number(b.id) || 0); + }); +} + +/** + * 模块默认落地页:统一优先「工作台 / 仪表盘」,其次侧栏排在最前的子菜单。 + * + * 直接取 children[0] 不可靠:后端按 ID 顺序返回,而侧栏是按 sort 展示的。 + * 早期先创建的页面(例如 ERP 的采购订单)会因此成为模块默认页, + * 即使「工作台」已经排在 sort=1。 + */ +export function pickDefaultChild(children) { + if (!children || children.length === 0) return null; + const sorted = sortByMenuOrder(children); + const workbench = + sorted.find(isWorkbenchMenu) || + // 工作台被放成二级目录(如 ERP:工作台 -> 仪表盘)时,选这个目录, + // 由该目录自己的 redirect 落到具体工作台页面 + sorted.find((child) => Array.isArray(child.children) && child.children.some(isWorkbenchMenu)); + return workbench || sorted[0]; +} diff --git a/backend/src/stores/menu.js b/backend/src/stores/menu.js index 61b0aaf..73aa347 100644 --- a/backend/src/stores/menu.js +++ b/backend/src/stores/menu.js @@ -3,6 +3,14 @@ import { ref, computed } from 'vue' // import { getUserInfo } from '@/utils/auth' import { getMenus } from '@/api/menu'; +// 是否属于「登录态失效」类错误。 +// request.js 已给鉴权错误打上 status=401,同时兼容历史文案与 axios 原始错误。 +const isAuthError = (err) => + err?.status === 401 || + err?.response?.status === 401 || + err?.isAuthError === true || + /token已过期|token无效|无效的token/.test(err?.message || ''); + export const useMenuStore = defineStore('menu', () => { // 菜单数据 const menus = ref([]); @@ -167,6 +175,18 @@ export const useMenuStore = defineStore('menu', () => { throw new Error(res.message || '获取菜单失败:响应格式错误'); } catch (err) { error.value = err.message || '获取菜单失败'; + + // 鉴权失败(令牌过期/无效、会话被吊销):清掉缓存并继续抛出, + // 由 request.js 静默续期或跳认证中心。 + // 不能回退到缓存:否则令牌过期时会用旧菜单把登录态问题掩盖掉, + // 用户看到的是一份已经失效的权限菜单。 + if (isAuthError(err)) { + clearCache(); + menus.value = []; + console.warn('获取菜单失败(登录状态失效):', err.message); + throw err; + } + console.error('获取菜单失败:', err); console.error('错误详情:', { message: err.message, @@ -174,13 +194,6 @@ export const useMenuStore = defineStore('menu', () => { stack: err.stack }); - // 如果是 token 无效错误,不使用缓存,直接抛出 - if (err.message === 'token无效' || err.response?.status === 401) { - clearCache(); - menus.value = []; - throw err; - } - // 如果出错,尝试使用缓存数据 const cachedMenus = loadFromCache(); if (cachedMenus && cachedMenus.length > 0) { diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js index 81433a3..90a0de6 100644 --- a/backend/src/utils/request.js +++ b/backend/src/utils/request.js @@ -13,7 +13,73 @@ const service = axios.create({ timeout: 300000, withCredentials: false // JWT 不需要 Cookie }); - + +// 构造鉴权失败错误:带上 status,调用方(如菜单 store)可直接按 401 判断, +// 不必依赖中文文案;否则文案一改,登录态判断就静默失效了。 +function authError(message) { + const err = new Error(message || '登录状态已失效,请重新登录'); + err.status = 401; + err.isAuthError = true; + return err; +} + +// 清空登录态并回认证中心重新登录(只跳一次,避免并发请求反复跳转) +function redirectToLoginOnce() { + clearTokens(); + localStorage.removeItem('userInfo'); + if (ssoRedirecting) return; + ssoRedirecting = true; + redirectToAuthorize().catch((err) => { + console.error('跳转统一认证失败:', err); + ssoRedirecting = false; + }); +} + +// 401 统一处理: +// 阶段一:并发场景下,本请求带的是旧令牌,而别的请求已换成新令牌 → 直接用最新令牌重放; +// 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次; +// 阶段三:续期失败 / 续期后仍 401 → 清登录态回认证中心。 +// 「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,只挡一次; +// 否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。 +async function recoverAuth(config, bizMsg) { + const latestToken = localStorage.getItem('token') || ''; + const sentToken = String(config?.headers?.Authorization || '').replace(/^Bearer\s+/i, ''); + if (!config?._replayedWithLatest && latestToken && latestToken !== sentToken) { + console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', config?.url); + config.headers['Authorization'] = `Bearer ${latestToken}`; + config._replayedWithLatest = true; + return service.request(config); + } + + if (config?._refreshed) { + // 续期拿到的令牌仍被判定过期/无效:继续留在页面只会一路报错,直接回认证中心 + redirectToLoginOnce(); + return Promise.reject(authError(bizMsg)); + } + + try { + const newToken = await refreshAccessToken(); + config.headers['Authorization'] = `Bearer ${newToken}`; + config._refreshed = true; + return service.request(config); + } catch (e) { + redirectToLoginOnce(); + return Promise.reject(authError('登录状态已失效,请重新登录')); + } +} + +// 令牌类错误提示。后端部分接口(全局鉴权中间件为 warn 模式时控制器自行鉴权) +// 会在 HTTP 200 的 body 里返回 code:401,需要按提示词识别。 +// 不能把所有 body code:401 都当令牌问题:个别接口(如新增用户时校验原密码) +// 也用 401 表示业务失败,那类响应重放没有意义,更不能因此把用户登出。 +const TOKEN_ERROR_HINTS = ['token已过期', '无效的token', '未提供认证信息', '认证信息格式错误']; + +function isTokenFailure(body) { + if (!body || Number(body.code) !== 401) return false; + const msg = String(body.msg || body.message || ''); + return TOKEN_ERROR_HINTS.some((hint) => msg.includes(hint)); +} + // 请求拦截器 service.interceptors.request.use( config => { @@ -38,11 +104,16 @@ service.interceptors.request.use( return Promise.reject(error); } ); - + // 响应拦截器 service.interceptors.response.use( response => { - return response.data; + const data = response.data; + // 兜住 HTTP 200 + body code:401 的令牌失效响应,让它走与真实 401 相同的续期流程 + if (isTokenFailure(data)) { + return recoverAuth(response.config, data.msg || data.message); + } + return data; }, async error => { if (error.response) { @@ -50,43 +121,8 @@ service.interceptors.response.use( // 避免页面只显示 "Request failed with status code xxx" const bizMsg = error.response.data?.msg || error.response.data?.message; switch (error.response.status) { - case 401: { - // 阶段一:并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的 - // (登录回跳、另一请求的静默续期都会改 localStorage)。 - // 此时直接用最新令牌重放,免得白跑一次续期。 - const latestToken = localStorage.getItem('token') || ''; - const sentToken = String(error.config?.headers?.Authorization || '').replace(/^Bearer\s+/i, ''); - if (!error.config?._replayedWithLatest && latestToken && latestToken !== sentToken) { - console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', error.config?.url); - error.config.headers['Authorization'] = `Bearer ${latestToken}`; - error.config._replayedWithLatest = true; - return service.request(error.config); - } - // 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次。 - // 注意「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token, - // 只挡一次;否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。 - if (error.config?._refreshed) { - return Promise.reject(new Error(bizMsg || 'token无效,请重新登录')); - } - try { - const newToken = await refreshAccessToken(); - error.config.headers['Authorization'] = `Bearer ${newToken}`; - error.config._refreshed = true; - return service.request(error.config); - } catch (e) { - // 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环) - clearTokens(); - localStorage.removeItem('userInfo'); - if (!ssoRedirecting) { - ssoRedirecting = true; - redirectToAuthorize().catch((err) => { - console.error('跳转统一认证失败:', err); - ssoRedirecting = false; - }); - } - return Promise.reject(new Error('token无效,请重新登录')); - } - } + case 401: + return recoverAuth(error.config, bizMsg); case 404: console.error('请求的资源不存在'); break; @@ -108,5 +144,5 @@ service.interceptors.response.use( return Promise.reject(error); } ); - -export default service; \ No newline at end of file + +export default service; diff --git a/backend/src/views/apps/oa/reimburse/components/detail.vue b/backend/src/views/apps/oa/reimburse/components/detail.vue index e4207b4..83969e8 100644 --- a/backend/src/views/apps/oa/reimburse/components/detail.vue +++ b/backend/src/views/apps/oa/reimburse/components/detail.vue @@ -20,10 +20,19 @@ 核对发票金额 - 添加费用 + 添加费用 + +
@@ -75,7 +84,7 @@
- + 保存发票金额 取消 @@ -105,7 +114,7 @@ - + @@ -191,7 +212,7 @@ {{ receipt.name }} - {{ actionLabel(record.action) }} · {{ record.approver_name || '-' }} -
{{ record.comment }}
+
+ {{ actionLabel(record.action) }} + 操作人:{{ record.approver_name || '-' }} +
+
操作理由:{{ record.comment || defaultRecordReason(record) }}
@@ -244,6 +268,14 @@