Commit 392ffd3a by 王立鹏

Merge branch '线上优化/分片上传相关优化' into 'master'

线上优化/分片上传相关优化

See merge request !38
parents 534f21d2 75af0216
import axios, { type AxiosRequestConfig } from 'axios' import axios, { type AxiosRequestConfig } from 'axios'
import CryptoJS from 'crypto-js' import CryptoJS from 'crypto-js'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import type { UploadFileResponseItem, ChunkCheckResponse } from './types' import type { UploadFileResponseItem, ChunkCheckResponse } from './types'
// 'https://oa.yswg.com.cn:8085' // 'https://oa.yswg.com.cn:8085'
...@@ -12,24 +11,40 @@ import type { UploadFileResponseItem, ChunkCheckResponse } from './types' ...@@ -12,24 +11,40 @@ import type { UploadFileResponseItem, ChunkCheckResponse } from './types'
// 20% nas2 // 20% nas2
// 40% nas3 // 40% nas3
export const uploadBaseUrlPool = [ export const uploadBaseUrlPool = [
{ url: "https://nas1.yswg.top:8088", weight: 40 }, { url: 'https://nas1.yswg.top:8088', weight: 40 },
{ url: "https://nas2.yswg.top:8088", weight: 20 }, { url: 'https://nas2.yswg.top:8088', weight: 20 },
{ url: "https://nas3.yswg.top:8088", weight: 40 }, { url: 'https://nas3.yswg.top:8088', weight: 40 },
]; ]
// 获取到nas上传地址 // 获取到nas上传地址
export const resolveUploadBaseUrl = () => { export const resolveUploadBaseUrl = () => {
const random = Math.random() * 100; const random = Math.random() * 100
if (random < 40) return uploadBaseUrlPool[0]!.url; if (random < 40) return uploadBaseUrlPool[0]!.url
if (random < 60) return uploadBaseUrlPool[1]!.url; if (random < 60) return uploadBaseUrlPool[1]!.url
return uploadBaseUrlPool[2]!.url; return uploadBaseUrlPool[2]!.url
}; }
const OA_UPLOAD_COMMON_BASE_URL = 'http://47.112.96.71:8082' const OA_UPLOAD_COMMON_BASE_URL = 'http://47.112.96.71:8082'
const CHUNK_UPLOAD_THRESHOLD = 1 * 1024 * 1024 const CHUNK_UPLOAD_THRESHOLD = 1 * 1024 * 1024
const CHUNK_SIZE = 5 * 1024 * 1024 const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_CONCURRENT_UPLOADS = 3 const MAX_CONCURRENT_UPLOADS = 3
const CHUNK_API_AUTH_SECRET = '4821-7395-1642-8053-2971-5604-9182-4307' const CHUNK_API_AUTH_SECRET = '4821-7395-1642-8053-2971-5604-9182-4307'
// nas 鉴权接口的超时时间 nas 挂掉时浏览器默认要等 20s 以上 这里主动掐断
const NAS_AUTH_TIMEOUT = 8000
// nas 连不上时统一对外抛这个文案 避免把 ERR_CONNECTION_REFUSED 这种原始错误甩给用户
export const UPLOAD_SERVICE_UNAVAILABLE_MSG = '视频上传服务暂时不可用,请稍后重试'
/**
* 把上传异常转成能给用户看的文案
* 只有我们自己标记过的服务不可用才原样展示
* 其它像 Network Error、Request failed with status code 500 这种原始报错一律兜底成通用文案
*/
export const resolveUploadErrorMessage = (error: unknown) => {
if (error instanceof Error && error.message === UPLOAD_SERVICE_UNAVAILABLE_MSG) {
return error.message
}
return '上传失败,请重试'
}
function hashBufferMD5(buffer: ArrayBuffer): string { function hashBufferMD5(buffer: ArrayBuffer): string {
const wordArray = CryptoJS.lib.WordArray.create(new Uint8Array(buffer)) const wordArray = CryptoJS.lib.WordArray.create(new Uint8Array(buffer))
...@@ -129,8 +144,7 @@ async function uploadFileByChunks( ...@@ -129,8 +144,7 @@ async function uploadFileByChunks(
}, },
signal: AbortSignal, signal: AbortSignal,
): Promise<UploadFileResponseItem> { ): Promise<UploadFileResponseItem> {
const userStore = useUserStore() const authToken = await ensureChunkApiAuthToken()
const { chunkApiAuthToken } = storeToRefs(userStore)
const { onProgress } = options const { onProgress } = options
const fileHash = await fileHashHeadTailMiddle(file) const fileHash = await fileHashHeadTailMiddle(file)
const fileSuffix = file.name.includes('.') ? file.name.slice(file.name.lastIndexOf('.')) : '' const fileSuffix = file.name.includes('.') ? file.name.slice(file.name.lastIndexOf('.')) : ''
...@@ -145,7 +159,7 @@ async function uploadFileByChunks( ...@@ -145,7 +159,7 @@ async function uploadFileByChunks(
fileSize: file.size, fileSize: file.size,
fileSuffx: fileSuffix, fileSuffx: fileSuffix,
}, },
{ signal, headers: { authorization: chunkApiAuthToken.value } }, { signal, headers: { authorization: authToken } },
) )
// 秒传服务检查文件是否存在 // 秒传服务检查文件是否存在
...@@ -191,7 +205,7 @@ async function uploadFileByChunks( ...@@ -191,7 +205,7 @@ async function uploadFileByChunks(
signal, signal,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
authorization: chunkApiAuthToken.value, authorization: authToken,
}, },
}) })
...@@ -233,7 +247,7 @@ async function uploadFileByChunks( ...@@ -233,7 +247,7 @@ async function uploadFileByChunks(
signal, signal,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
authorization: chunkApiAuthToken.value, authorization: authToken,
}, },
}, },
) )
...@@ -298,7 +312,7 @@ export const uploadFile = ( ...@@ -298,7 +312,7 @@ export const uploadFile = (
export const getTimestamp = async () => { export const getTimestamp = async () => {
const { const {
data: { data: timestamp }, data: { data: timestamp },
} = await axios.get(`${resolveUploadBaseUrl()}/api/auth/getTime`) } = await axios.get(`${resolveUploadBaseUrl()}/api/auth/getTime`, { timeout: NAS_AUTH_TIMEOUT })
return timestamp return timestamp
} }
...@@ -308,10 +322,36 @@ export const getChunkApiAuthToken = async (weChatId: string) => { ...@@ -308,10 +322,36 @@ export const getChunkApiAuthToken = async (weChatId: string) => {
const rawStr = CHUNK_API_AUTH_SECRET + timestamp const rawStr = CHUNK_API_AUTH_SECRET + timestamp
const { const {
data: { data }, data: { data },
} = await axios.post(`${resolveUploadBaseUrl()}/api/auth/getToken`, { } = await axios.post(
timestamp, `${resolveUploadBaseUrl()}/api/auth/getToken`,
weChatId, {
secret: CryptoJS.MD5(rawStr).toString(), timestamp,
}) weChatId,
secret: CryptoJS.MD5(rawStr).toString(),
},
{ timeout: NAS_AUTH_TIMEOUT },
)
return data.token as string return data.token as string
} }
/**
* 分片上传前确保拿到 nas 的鉴权 token
* 登录时拿不到 token 不会阻断登录(nas 挂了不该让人进不去系统)
* 所以真正要用的时候在这里补一次 补不到就抛友好文案 让上层弹提示
*/
const ensureChunkApiAuthToken = async () => {
const userStore = useUserStore()
if (userStore.chunkApiAuthToken) return userStore.chunkApiAuthToken
const account = userStore.userInfo?.account
if (!account) throw new Error(UPLOAD_SERVICE_UNAVAILABLE_MSG)
try {
const token = await getChunkApiAuthToken(account)
userStore.setChunkApiAuthToken(token)
return token
} catch (error) {
console.error('获取分片上传 token 失败', error)
throw new Error(UPLOAD_SERVICE_UNAVAILABLE_MSG)
}
}
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
<script lang="ts" setup generic="T extends string | string[]"> <script lang="ts" setup generic="T extends string | string[]">
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { uploadFile as uploadFileApi } from '@/api' import { uploadFile as uploadFileApi, resolveUploadErrorMessage } from '@/api'
import type { UploadProps, UploadUserFile } from 'element-plus' import type { UploadProps, UploadUserFile } from 'element-plus'
import type { UploadFileProps } from './types' import type { UploadFileProps } from './types'
import { push } from 'notivue' import { push } from 'notivue'
...@@ -156,7 +156,7 @@ const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) => ...@@ -156,7 +156,7 @@ const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) =>
} }
} catch (error) { } catch (error) {
console.error('上传失败:', error) console.error('上传失败:', error)
push.error('上传失败,请重试') push.error(resolveUploadErrorMessage(error))
const fileIndex = fileList.value.findIndex((file) => file.uid === uid) const fileIndex = fileList.value.findIndex((file) => file.uid === uid)
if (fileIndex !== -1) { if (fileIndex !== -1) {
......
...@@ -83,7 +83,7 @@ ...@@ -83,7 +83,7 @@
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">
import { uploadFile as uploadFileApi } from '@/api/common' import { uploadFile as uploadFileApi, resolveUploadErrorMessage } from '@/api/common'
import type { UploadFile } from 'element-plus' import type { UploadFile } from 'element-plus'
import type { UploadVideoProps } from './types' import type { UploadVideoProps } from './types'
import { getVideoMetadata } from '@/utils' import { getVideoMetadata } from '@/utils'
...@@ -275,7 +275,8 @@ const startUpload = async () => { ...@@ -275,7 +275,8 @@ const startUpload = async () => {
push.success('视频上传成功!') push.success('视频上传成功!')
} catch (error) { } catch (error) {
uploading.value = false uploading.value = false
uploadError.value = error instanceof Error ? error.message : '上传失败,请重试' console.error('视频上传失败', error)
uploadError.value = resolveUploadErrorMessage(error)
push.error(uploadError.value) push.error(uploadError.value)
} finally { } finally {
if (localVideoUrl) { if (localVideoUrl) {
......
...@@ -41,6 +41,13 @@ app.directive('parse-comment', vParseComment) ...@@ -41,6 +41,13 @@ app.directive('parse-comment', vParseComment)
// 全局组件挂载 // 全局组件挂载
app.component('SvgIcon', SvgIcon) app.component('SvgIcon', SvgIcon)
router.isReady().then(() => { // 首次导航的守卫一旦抛错 isReady 会 reject 页面就永远挂不上去(整屏白屏)
app.mount('#app') // 所以这里兜底 无论首次导航成功与否都要把应用挂载出来
}) router
.isReady()
.catch((error) => {
console.error('首次路由导航失败 仍然挂载应用', error)
})
.then(() => {
app.mount('#app')
})
...@@ -4,6 +4,7 @@ import { saveScrollPosition } from './scrollStorage' ...@@ -4,6 +4,7 @@ import { saveScrollPosition } from './scrollStorage'
import { parseCode } from '@/utils/wxUtil' import { parseCode } from '@/utils/wxUtil'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { finishPageProgress, startPageProgress } from '@/components/common/Progress/usePageProgress' import { finishPageProgress, startPageProgress } from '@/components/common/Progress/usePageProgress'
import { push } from 'notivue'
// 白名单 // 白名单
const WHITE_LIST: string[] = ['/aa'] const WHITE_LIST: string[] = ['/aa']
...@@ -30,10 +31,17 @@ export function registerRouterGuards(router: Router) { ...@@ -30,10 +31,17 @@ export function registerRouterGuards(router: Router) {
if (code) { if (code) {
// 如果有token 就不需要重新获取用户信息 // 如果有token 就不需要重新获取用户信息
// if (!userStore.token || !userStore.refreshToken) { // if (!userStore.token || !userStore.refreshToken) {
await userStore.getUserInfoByCode({ try {
code, await userStore.getUserInfoByCode({
isCodeLogin: 0, code,
}) isCodeLogin: 0,
})
} catch (error) {
// 守卫里抛错会直接中断导航 页面白屏 什么提示都没有
// 这里兜住 给个提示再照常进页面 后续接口 401 会走重新登录的逻辑
console.error('企微 code 登录失败', error)
push.error('登录失败,请刷新页面重试')
}
// } // }
return { return {
path: to.path, path: to.path,
......
...@@ -45,8 +45,15 @@ export const useUserStore = defineStore('user', () => { ...@@ -45,8 +45,15 @@ export const useUserStore = defineStore('user', () => {
setUserInfoAndToken(data) setUserInfoAndToken(data)
// 同时获取关于分片的token // 同时获取关于分片的token
const token = await getChunkApiAuthToken(data.account) // 这个 token 走的是 nas 那边的接口 跟登录、跟首页展示都没有依赖关系
setChunkApiAuthToken(token) // 所以不 await 直接放后台跑 让首页尽快出来 nas 慢或者挂了也不会拖住登录
// 这里的 catch 不能省 不然 nas 挂掉时会变成未处理的 promise rejection
// 万一没拿到 等真正要分片上传的时候会再补一次(见 ensureChunkApiAuthToken)
getChunkApiAuthToken(data.account)
.then(setChunkApiAuthToken)
.catch((error) => {
console.error('获取分片上传 token 失败 不影响登录', error)
})
} }
const getNewToken = async () => { const getNewToken = async () => {
const { data } = await refreshTokenApi(refreshToken.value) const { data } = await refreshTokenApi(refreshToken.value)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment