Browse Source

fix(upload): 修复文档上传多文件失效与 401 跳登录

- 上传改走 axios 复用 401 自动刷新,显式 multipart/form-data 修复 MultipartException
- DocUpload 文件 Tab 加 multiple,上传 handler 按 TDesign requestMethod 契约重写
- AuthController 的 refresh cookie Secure 属性按请求 scheme 动态判断,HTTP 自动降级
- application.yml / application-dev.yml 统一 refresh-cookie-secure 配置
Spring-AI-1.1.2
wanghanlin 2 weeks ago
parent
commit
9d5fe80fb3
  1. 34
      frontend/src/api/upload.ts
  2. 58
      frontend/src/views/DocUpload.vue
  3. 23
      src/main/java/com/wok/supportbot/auth/AuthController.java
  4. 3
      src/main/resources/application-dev.yml
  5. 2
      src/main/resources/application.yml

34
frontend/src/api/upload.ts

@ -1,26 +1,22 @@
import { getToken } from '@/utils/token'
import request from './request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
/** XMLHttpRequest 上传(带进度回调)—— 保留旧版 api.js 的核心实现 */
function postFormWithProgress(path: string, formData: FormData, onProgress?: (pct: number) => void): Promise<any> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', path)
const token = getToken()
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) {
/**
* axios request
* Bearer Token401
* axios onUploadProgress
*/
function postFormWithProgress(path: string, formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return request.post(path, formData, {
// 必须显式指定 multipart/form-data:request 实例默认 Content-Type 为 application/json,
// 若不覆盖,后端会将请求视为非 multipart 而抛 MultipartException(边界由浏览器自动补充)。
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100)) onProgress(Math.round((e.loaded / e.total) * 100))
} }
})
xhr.addEventListener('load', () => {
if (xhr.status === 401) { localStorage.removeItem('sb_token'); localStorage.removeItem('sb_user'); window.dispatchEvent(new Event('auth:unauthorized')); reject(new Error('401')); return }
try { resolve(JSON.parse(xhr.responseText)) } catch { reject(new Error('响应解析失败')) }
})
xhr.addEventListener('error', () => reject(new Error('网络错误')))
xhr.addEventListener('abort', () => reject(new Error('上传已取消')))
xhr.send(formData)
})
},
}).then(r => r.data)
} }
/** 上传文件(带进度) */ /** 上传文件(带进度) */

58
frontend/src/views/DocUpload.vue

@ -15,7 +15,7 @@
<!-- 上传模式 Tab --> <!-- 上传模式 Tab -->
<t-tabs v-model="uploadMode"> <t-tabs v-model="uploadMode">
<t-tab-panel value="file" label="📄 文件上传"> <t-tab-panel value="file" label="📄 文件上传">
<t-upload :auto-upload="false" theme="file-flow" :before-upload="beforeFileUpload"
<t-upload :auto-upload="false" theme="file-flow" multiple :before-upload="beforeFileUpload"
:request-method="handleFileUpload" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.json,.csv,.html,.xml,.rtf" :request-method="handleFileUpload" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.json,.csv,.html,.xml,.rtf"
:size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }" /> :size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }" />
</t-tab-panel> </t-tab-panel>
@ -36,9 +36,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { UploadFile } from 'tdesign-vue-next'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { useDocumentStore } from '@/stores/document' import { useDocumentStore } from '@/stores/document'
import { uploadFile, uploadMarkdown } from '@/api/upload'
import { uploadFile, uploadMarkdown, uploadJsonBasic } from '@/api/upload'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
@ -87,41 +88,46 @@ function beforeFileUpload(file: File): boolean {
return true return true
} }
async function handleFileUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
/** 从 TDesign requestMethod 入参中取出原始 File(单文件为 UploadFile,多文件为数组) */
function pickRawFile(file: UploadFile | UploadFile[]): File | undefined {
const f = Array.isArray(file) ? file[0] : file
return f?.raw
}
async function handleFileUpload(file: UploadFile | UploadFile[]) {
try { try {
const fd = buildFormData(req.file)
await uploadFile(fd, (pct) => { if (req.onProgress) req.onProgress({ percent: pct }) })
req.onSuccess()
const raw = pickRawFile(file)
if (!raw) return { status: 'fail', error: '未获取到文件', response: {} }
const fd = buildFormData(raw)
await uploadFile(fd)
toast('上传成功,文档正在处理中', 'success') toast('上传成功,文档正在处理中', 'success')
documentStore.loadStats() documentStore.loadStats()
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
return { status: 'success', response: {} }
} catch (e: any) { toast('上传失败:' + e.message, 'error'); return { status: 'fail', error: e.message, response: {} } }
} }
async function handleMdUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
async function handleMdUpload(file: UploadFile | UploadFile[]) {
try { try {
const fd = buildFormData(req.file)
await uploadMarkdown(fd, (pct) => { if (req.onProgress) req.onProgress({ percent: pct }) })
req.onSuccess()
const raw = pickRawFile(file)
if (!raw) return { status: 'fail', error: '未获取到文件', response: {} }
const fd = buildFormData(raw)
await uploadMarkdown(fd)
toast('Markdown 上传成功', 'success') toast('Markdown 上传成功', 'success')
documentStore.loadStats() documentStore.loadStats()
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
return { status: 'success', response: {} }
} catch (e: any) { toast('上传失败:' + e.message, 'error'); return { status: 'fail', error: e.message, response: {} } }
} }
async function handleJsonUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
// JSON 使 fetch
async function handleJsonUpload(file: UploadFile | UploadFile[]) {
try { try {
const fd = buildFormData(req.file)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/upload/json/basic')
const token = localStorage.getItem('sb_token') || ''
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && req.onProgress) req.onProgress({ percent: Math.round((e.loaded / e.total) * 100) })
})
xhr.addEventListener('load', () => { req.onSuccess(); toast('JSON 上传成功', 'success'); documentStore.loadStats() })
xhr.addEventListener('error', () => { req.onError(); toast('上传失败', 'error') })
xhr.send(fd)
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
const raw = pickRawFile(file)
if (!raw) return { status: 'fail', error: '未获取到文件', response: {} }
const fd = buildFormData(raw)
await uploadJsonBasic(fd)
toast('JSON 上传成功', 'success')
documentStore.loadStats()
return { status: 'success', response: {} }
} catch (e: any) { toast('上传失败:' + e.message, 'error'); return { status: 'fail', error: e.message, response: {} } }
} }
async function uploadText() { async function uploadText() {

23
src/main/java/com/wok/supportbot/auth/AuthController.java

@ -6,6 +6,7 @@ import com.wok.supportbot.security.JwtTokenProvider;
import com.wok.supportbot.security.LoginAttemptService; import com.wok.supportbot.security.LoginAttemptService;
import com.wok.supportbot.security.PasswordValidator; import com.wok.supportbot.security.PasswordValidator;
import com.wok.supportbot.service.SysUserService; import com.wok.supportbot.service.SysUserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -63,7 +64,7 @@ public class AuthController {
* 登录成功后通过 httpOnly Cookie 返回 refresh tokenbody 中只返回 access token * 登录成功后通过 httpOnly Cookie 返回 refresh tokenbody 中只返回 access token
*/ */
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> body) {
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> body, HttpServletRequest request) {
String username = body.get("username"); String username = body.get("username");
String password = body.get("password"); String password = body.get("password");
if (username == null || password == null || username.isBlank() || password.isBlank()) { if (username == null || password == null || username.isBlank() || password.isBlank()) {
@ -102,7 +103,7 @@ public class AuthController {
loginAttemptService.recordSuccess(username); loginAttemptService.recordSuccess(username);
Map<String, Object> userInfo = buildUserInfo(user, roleKeys); Map<String, Object> userInfo = buildUserInfo(user, roleKeys);
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L);
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L, request.isSecure());
log.info("用户登录成功: {}", username); log.info("用户登录成功: {}", username);
return ResponseEntity.ok() return ResponseEntity.ok()
@ -135,7 +136,8 @@ public class AuthController {
*/ */
@PostMapping("/refresh") @PostMapping("/refresh")
public ResponseEntity<Map<String, Object>> refresh( public ResponseEntity<Map<String, Object>> refresh(
@CookieValue(name = "refresh_token", required = false) String refreshToken) {
@CookieValue(name = "refresh_token", required = false) String refreshToken,
HttpServletRequest request) {
if (refreshToken == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) { if (refreshToken == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) {
return ResponseEntity.status(401).body(Map.of( return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "刷新令牌无效或已过期" "success", false, "message", "刷新令牌无效或已过期"
@ -162,7 +164,7 @@ public class AuthController {
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer); String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer); String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer);
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, 7 * 24 * 60 * 60L);
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, 7 * 24 * 60 * 60L, request.isSecure());
return ResponseEntity.ok() return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) .header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
@ -176,7 +178,7 @@ public class AuthController {
* 退出登录清除 refresh token cookie并使当前 access token 立即失效 * 退出登录清除 refresh token cookie并使当前 access token 立即失效
*/ */
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout() {
public ResponseEntity<Map<String, Object>> logout(HttpServletRequest request) {
// 递增 token_version使已签发的 access token 立即失效剩余有效期内的令牌不再可用 // 递增 token_version使已签发的 access token 立即失效剩余有效期内的令牌不再可用
// 注意改密/禁用后 token 已失效SecurityContext 可能为空此时仅清除 cookie 即可 // 注意改密/禁用后 token 已失效SecurityContext 可能为空此时仅清除 cookie 即可
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
@ -189,7 +191,7 @@ public class AuthController {
ResponseCookie clearCookie = ResponseCookie.from("refresh_token", "") ResponseCookie clearCookie = ResponseCookie.from("refresh_token", "")
.httpOnly(true) .httpOnly(true)
.secure(refreshCookieSecure)
.secure(refreshCookieSecure && request.isSecure())
.sameSite("Strict") .sameSite("Strict")
.path("/auth/refresh") .path("/auth/refresh")
.maxAge(0) .maxAge(0)
@ -277,10 +279,15 @@ public class AuthController {
return userInfo; return userInfo;
} }
private ResponseCookie buildRefreshCookie(String refreshToken, long maxAgeSeconds) {
/**
* 构建 refresh token Cookie
* Secure 属性按配置开关 && 当前请求是否 HTTPS动态决定
* 浏览器仅在 HTTPS 下接受 Secure cookie本地 HTTP 开发环境必须降级为 false否则刷新会失效
*/
private ResponseCookie buildRefreshCookie(String refreshToken, long maxAgeSeconds, boolean isSecureRequest) {
return ResponseCookie.from("refresh_token", refreshToken) return ResponseCookie.from("refresh_token", refreshToken)
.httpOnly(true) .httpOnly(true)
.secure(refreshCookieSecure)
.secure(refreshCookieSecure && isSecureRequest)
.sameSite("Strict") .sameSite("Strict")
.path("/auth/refresh") .path("/auth/refresh")
.maxAge(maxAgeSeconds) .maxAge(maxAgeSeconds)

3
src/main/resources/application-dev.yml

@ -20,7 +20,8 @@ knife4j:
# ==================== JWT 开发配置 ==================== # ==================== JWT 开发配置 ====================
jwt: jwt:
refresh-cookie-secure: false
# Secure 属性由 AuthController 按请求 scheme 动态决定(HTTP 自动降级为 false),此处与生产保持一致
refresh-cookie-secure: true
fail-if-default-secret: false fail-if-default-secret: false
# ==================== CORS 开发配置 ==================== # ==================== CORS 开发配置 ====================

2
src/main/resources/application.yml

@ -127,7 +127,7 @@ jwt:
expiration: 900000 expiration: 900000
# 刷新令牌有效期:7天(毫秒) # 刷新令牌有效期:7天(毫秒)
refresh-expiration: 604800000 refresh-expiration: 604800000
# refresh token cookie 是否标记 Secure(生产必须 true,本地开发通过 application-dev.yml 覆盖为 false)
# refresh token cookie 是否标记 Secure(作为总开关;实际 Secure 属性由 AuthController 按请求是否 HTTPS 动态决定,HTTP 自动降级为 false)
refresh-cookie-secure: true refresh-cookie-secure: true
# 使用默认密钥时是否直接启动失败(生产建议 true) # 使用默认密钥时是否直接启动失败(生产建议 true)
fail-if-default-secret: false fail-if-default-secret: false

Loading…
Cancel
Save