From ee49ef15b5344df3783756ed7689b331a5b7eaf6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com>
Date: Fri, 6 Nov 2026 01:13:28 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8A=A5=E9=94=80=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E5=A2=9E=E5=8A=A0=E6=8C=82=E9=9D=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/src/api/reimburse.js | 10 +
backend/src/main.js | 44 +--
backend/src/router/index.js | 19 +-
backend/src/stores/auth.js | 7 +-
backend/src/utils/authClient.js | 71 +++-
backend/src/utils/request.js | 8 +-
.../apps/oa/reimburse/components/detail.vue | 184 ++++++++++-
.../oa/reimburse/components/invoicePicker.vue | 193 +++++++++++
.../apps/oa/reimburse/components/itemForm.vue | 99 +++++-
backend/src/views/apps/oa/reimburse/index.vue | 34 +-
backend/src/views/auth/appLogin.vue | 5 +-
backend/src/views/auth/mpLogin.vue | 5 +-
go/controllers/app_oa_expense.go | 2 +-
go/controllers/auth/login.go | 82 ++++-
go/controllers/auth/oidc.go | 17 +-
go/controllers/auth/third.go | 6 +
go/controllers/backend_reimburse.go | 304 +++++++++++++++++-
.../sql/alter_oa_reimburse_invoice_link.sql | 71 ++++
go/models/reimburse.go | 4 +
go/routers/auth/auth.go | 1 +
go/routers/backend/backend.go | 1 +
go/services/auth/session.go | 41 +++
go/views/auth/login.tpl | 121 ++++++-
uniapp/api/expense.js | 8 +-
24 files changed, 1267 insertions(+), 70 deletions(-)
create mode 100644 backend/src/views/apps/oa/reimburse/components/invoicePicker.vue
create mode 100644 go/docs/sql/alter_oa_reimburse_invoice_link.sql
diff --git a/backend/src/api/reimburse.js b/backend/src/api/reimburse.js
index 8ac4c74..e31f2f2 100644
--- a/backend/src/api/reimburse.js
+++ b/backend/src/api/reimburse.js
@@ -70,6 +70,16 @@ export function updateReimbursementStatus(id, data) {
})
}
+// 可挂靠发票:只返回「当前报销单」内上传、票面金额已维护且仍有余额的发票
+// reimbursement_id 必填;exclude_item_id 用于编辑明细时排除它自身的占用
+export function getAvailableInvoices(params) {
+ return request({
+ url: '/backend/reimbursements/invoices/available',
+ method: 'get',
+ params
+ })
+}
+
export function getReimbursementRecords(id) {
return request({
url: `/backend/reimbursements/${id}/records`,
diff --git a/backend/src/main.js b/backend/src/main.js
index e66b4b9..c4cf6b2 100644
--- a/backend/src/main.js
+++ b/backend/src/main.js
@@ -14,7 +14,7 @@ import router from './router'
import { loadAndAddDynamicRoutes } from './router'
import { createPinia } from 'pinia'
import { useAuthStore } from './stores/auth'
-import { redirectToAuthorize } from '@/utils/authClient'
+import { redirectToAuthorize, isAuthHandoff } from '@/utils/authClient'
// import { initTheme } from './utils/theme'
// 导入全局组件
import UmoEditor from '@/views/components/UmoEditor.vue';
@@ -40,26 +40,32 @@ app.use(VueViewer)
// 初始化主题(必须在挂载前执行)
// initTheme()
-// 初始化时检查认证状态
+// 初始化时检查认证状态。
+// 认证交接页(/auth/callback、/app-login、/mp-login)正在兑换令牌,此时
+// localStorage 里还是旧 token。启动期请求带旧 token 打到后端会触发 401 自动
+// 恢复,进而清掉刚换到的新令牌并重新跳回登录页。交接页一律跳过启动期的
+// 登录态请求与自动跳转,由交接页写完令牌后整页重载。
const authStore = useAuthStore()
-authStore.checkAuth()
+if (!isAuthHandoff()) {
+ authStore.checkAuth()
-// 如果用户已登录,在应用启动时加载动态路由
-if (authStore.isLoggedIn) {
- loadAndAddDynamicRoutes()
- .catch(err => {
- console.error('应用启动时加载动态路由失败:', err);
- })
- .then(() => {
- // 检查是否因为 token 无效而导致路由加载失败
- const token = localStorage.getItem('token');
- if (!token) {
- // 本地登录已停用:直接回认证中心重新登录(不再跳 #/login)
- redirectToAuthorize().catch((err) => {
- console.error('跳转统一认证失败:', err);
- });
- }
- });
+ // 如果用户已登录,在应用启动时加载动态路由
+ if (authStore.isLoggedIn) {
+ loadAndAddDynamicRoutes()
+ .catch(err => {
+ console.error('应用启动时加载动态路由失败:', err);
+ })
+ .then(() => {
+ // 检查是否因为 token 无效而导致路由加载失败
+ const token = localStorage.getItem('token');
+ if (!token) {
+ // 本地登录已停用:直接回认证中心重新登录(不再跳 #/login)
+ redirectToAuthorize().catch((err) => {
+ console.error('跳转统一认证失败:', err);
+ });
+ }
+ });
+ }
}
app.mount('#app')
\ No newline at end of file
diff --git a/backend/src/router/index.js b/backend/src/router/index.js
index 1b83311..a732133 100644
--- a/backend/src/router/index.js
+++ b/backend/src/router/index.js
@@ -344,13 +344,15 @@ function findFirstValidRoute(routes) {
return null;
}
-/** 跳转统一认证中心;地址未配置时给出提示,避免白屏 */
+/** 跳转统一认证中心;地址未配置/检测到循环时给出提示,避免白屏或无限跳转 */
async function gotoAuthorize() {
try {
await redirectToAuthorize();
+ return true;
} catch (e) {
console.error(e);
window.alert(e?.message || "统一认证跳转失败,请检查认证中心配置");
+ return false;
}
}
@@ -375,14 +377,16 @@ router.beforeEach(async (to, from, next) => {
}
next({ path: "/home" });
} else {
- await gotoAuthorize();
+ const ok = await gotoAuthorize();
+ if (!ok) next(false);
}
return;
}
// 未登录:统一走认证中心(本地登录已停用)
if (!token) {
- await gotoAuthorize();
+ const ok = await gotoAuthorize();
+ if (!ok) next(false);
return;
}
@@ -391,7 +395,14 @@ router.beforeEach(async (to, from, next) => {
// 令牌失效时 ensureUserInfo 会清空登录态并返回 null,此时重新登录。
const info = await ensureUserInfo();
if (!info) {
- await gotoAuthorize();
+ // 只有登录态确实被清空(确认令牌失效)才重新授权;userinfo 接口临时故障
+ // 时保留 token 放行,避免「登录成功 → 接口抖动 → 回认证中心」的循环。
+ if (localStorage.getItem("token")) {
+ next();
+ return;
+ }
+ const ok = await gotoAuthorize();
+ if (!ok) next(false);
return;
}
diff --git a/backend/src/stores/auth.js b/backend/src/stores/auth.js
index 3c8886a..29b7049 100644
--- a/backend/src/stores/auth.js
+++ b/backend/src/stores/auth.js
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import { getCurrentUser } from '@/api/login'
-import { logoutSSO } from '@/utils/authClient'
+import { logoutSSO, isAuthHandoff } from '@/utils/authClient'
// 用户信息类型
const defaultUser = {
@@ -53,8 +53,9 @@ export const useAuthStore = defineStore('auth', () => {
// 初始化时加载用户信息
loadUserFromCache()
- // 若已登录但缺少租户名称,异步从后端补齐
- if (token.value) {
+ // 若已登录但缺少租户名称,异步从后端补齐;
+ // 认证交接页除外(此时 token 是旧的,请求会 401 并干扰换码)
+ if (token.value && !isAuthHandoff()) {
fetchCurrentUser()
}
diff --git a/backend/src/utils/authClient.js b/backend/src/utils/authClient.js
index 77f4ff3..d854641 100644
--- a/backend/src/utils/authClient.js
+++ b/backend/src/utils/authClient.js
@@ -45,6 +45,51 @@ export function hasAuthBase() {
return !!AUTH_BASE;
}
+// 认证「交接页」:正在用 code / ticket 兑换令牌。此时 localStorage 里还是
+// 上一次的旧 token,页面启动期的请求一旦 401,自动恢复逻辑会把刚换到的新
+// token 清掉并重新跳认证中心,形成「登录成功又回登录页」的循环。
+const HANDOFF_PATHS = ["/auth/callback", "/app-login", "/mp-login"];
+const REDIRECT_LOG_KEY = "auth_authorize_redirects";
+const LOOP_WINDOW_MS = 15000;
+const LOOP_MAX = 3;
+
+export function isAuthHandoff() {
+ const hash = window.location.hash || "";
+ const hashPath = hash.startsWith("#") ? hash.slice(1).split("?")[0] : "";
+ if (HANDOFF_PATHS.includes(hashPath)) return true;
+ if (window.location.pathname === "/auth-callback.html") return true;
+ // 兼容中转页把 code/ticket 放在 hash 之前的场景;只看 query,避免业务页面
+ // 自身的 ?token= 等参数被误判成交接页。
+ return /[?&](code|ticket)=/.test(window.location.search || "");
+}
+
+// 短时间内反复发起授权说明出现了重定向环:停下并给出可诊断的错误,
+// 而不是让用户一直被弹回登录界面。
+function guardAuthorizeLoop() {
+ const now = Date.now();
+ let times = [];
+ try {
+ times = JSON.parse(sessionStorage.getItem(REDIRECT_LOG_KEY) || "[]");
+ } catch (e) {
+ times = [];
+ }
+ times = (Array.isArray(times) ? times : []).filter((t) => now - Number(t) < LOOP_WINDOW_MS);
+ if (times.length >= LOOP_MAX) {
+ throw new Error(
+ "短时间内多次跳转统一登录,已停止自动跳转以避免循环;请检查认证中心与回跳白名单配置后重试"
+ );
+ }
+ times.push(now);
+ try {
+ sessionStorage.setItem(REDIRECT_LOG_KEY, JSON.stringify(times));
+ } catch (e) {
+ // 隐私模式下 sessionStorage 不可写,忽略即可
+ }
+}
+
+// 计数按时间窗口自然过期,不在换码成功后清零:否则「换码成功 → 401 → 又跳
+// 认证中心」这类循环每次都会把计数抹掉,保护永远无法触发。
+
// ---------------------------------------------------------------- PKCE
function randomString(len = 64) {
@@ -108,6 +153,7 @@ export async function redirectToAuthorize() {
if (!AUTH_BASE) {
throw new Error("统一认证地址未配置:请检查 VITE_AUTH_BASE 或 VITE_API_BASE_URL");
}
+ guardAuthorizeLoop();
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem("pkce_verifier", verifier);
const state = randomString(24);
@@ -132,6 +178,12 @@ export async function redirectToAuthorize() {
/** 处理认证中心回跳:用 code 换令牌 */
export async function handleAuthorizeCallback(query = {}) {
+ // 交接页加载期间,启动期请求是用旧 token 发出的。先清掉旧登录态,
+ // 避免并发 401 续期拿旧 refresh_token 触发「重放吊销」把新会话一起废掉,
+ // 也避免旧 userInfo 被误当成新会话的用户信息。
+ clearTokens();
+ localStorage.removeItem("userInfo");
+
const code = query.code;
const state = query.state;
const savedState = sessionStorage.getItem("oidc_state");
@@ -274,10 +326,15 @@ export async function ensureUserInfo() {
localStorage.setItem('userInfo', JSON.stringify(normalized));
return normalized;
} catch (e) {
- // 令牌无效(例如迁移前的旧令牌):清掉本地状态,让调用方重新登录
- clearTokens();
- localStorage.removeItem('userInfo');
- return null;
+ // 只有确认令牌失效(401)才清空登录态。userinfo 接口网络抖动 / 404 / 5xx
+ // 属于临时故障,一律清 token 会把刚登录成功的用户反复送回认证中心。
+ if (e?.status === 401 || e?.isAuthError) {
+ clearTokens();
+ localStorage.removeItem('userInfo');
+ return null;
+ }
+ console.warn('[auth] 获取用户信息失败,保留当前登录态:', e);
+ return info && info.id ? info : null;
}
}
@@ -298,7 +355,11 @@ export async function fetchUserInfo() {
// 续期失败:保持原响应,交给调用方按「未登录」处理
}
}
- if (!res.ok) throw new Error("获取用户信息失败");
+ if (!res.ok) {
+ const err = new Error(`获取用户信息失败(${res.status})`);
+ err.status = res.status;
+ throw err;
+ }
return res.json();
}
diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js
index 90a0de6..4c8ada3 100644
--- a/backend/src/utils/request.js
+++ b/backend/src/utils/request.js
@@ -1,5 +1,5 @@
import axios from 'axios';
-import { refreshAccessToken, clearTokens, redirectToAuthorize } from '@/utils/authClient';
+import { refreshAccessToken, clearTokens, redirectToAuthorize, isAuthHandoff } from '@/utils/authClient';
// 统一认证模式下 401 后正在跳转登录的标志,避免并发请求同时触发跳转造成死循环
let ssoRedirecting = false;
@@ -25,6 +25,9 @@ function authError(message) {
// 清空登录态并回认证中心重新登录(只跳一次,避免并发请求反复跳转)
function redirectToLoginOnce() {
+ // 交接页里的 401 来自旧 token,不代表刚换到的新登录态失效。
+ // 在这里清 token / 跳认证中心会造成「登录成功后又回登录页」。
+ if (isAuthHandoff()) return;
clearTokens();
localStorage.removeItem('userInfo');
if (ssoRedirecting) return;
@@ -42,6 +45,9 @@ function redirectToLoginOnce() {
// 「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,只挡一次;
// 否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
async function recoverAuth(config, bizMsg) {
+ // 交接页的 401 来自旧 token,不能用旧 refresh_token 续期(会触发重放吊销),
+ // 也不能重放或自动跳转;换码完成后整页重载即可恢复正常请求。
+ if (isAuthHandoff()) return Promise.reject(authError('正在完成登录,请稍候'));
const latestToken = localStorage.getItem('token') || '';
const sentToken = String(config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
if (!config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
diff --git a/backend/src/views/apps/oa/reimburse/components/detail.vue b/backend/src/views/apps/oa/reimburse/components/detail.vue
index 83969e8..b937737 100644
--- a/backend/src/views/apps/oa/reimburse/components/detail.vue
+++ b/backend/src/views/apps/oa/reimburse/components/detail.vue
@@ -7,9 +7,11 @@