Commit 392ffd3a by 王立鹏

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

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

See merge request !38
parents 534f21d2 75af0216
import axios, { type AxiosRequestConfig } from 'axios'
import CryptoJS from 'crypto-js'
import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import type { UploadFileResponseItem, ChunkCheckResponse } from './types'
// 'https://oa.yswg.com.cn:8085'
......@@ -12,24 +11,40 @@ import type { UploadFileResponseItem, ChunkCheckResponse } from './types'
// 20% nas2
// 40% nas3
export const uploadBaseUrlPool = [
{ url: "https://nas1.yswg.top:8088", weight: 40 },
{ url: "https://nas2.yswg.top:8088", weight: 20 },
{ url: "https://nas3.yswg.top:8088", weight: 40 },
];
{ url: 'https://nas1.yswg.top:8088', weight: 40 },
{ url: 'https://nas2.yswg.top:8088', weight: 20 },
{ url: 'https://nas3.yswg.top:8088', weight: 40 },
]
// 获取到nas上传地址
export const resolveUploadBaseUrl = () => {
const random = Math.random() * 100;
if (random < 40) return uploadBaseUrlPool[0]!.url;
if (random < 60) return uploadBaseUrlPool[1]!.url;
return uploadBaseUrlPool[2]!.url;
};
const random = Math.random() * 100
if (random < 40) return uploadBaseUrlPool[0]!.url
if (random < 60) return uploadBaseUrlPool[1]!.url
return uploadBaseUrlPool[2]!.url
}
const OA_UPLOAD_COMMON_BASE_URL = 'http://47.112.96.71:8082'
const CHUNK_UPLOAD_THRESHOLD = 1 * 1024 * 1024
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_CONCURRENT_UPLOADS = 3
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 {
const wordArray = CryptoJS.lib.WordArray.create(new Uint8Array(buffer))
......@@ -129,8 +144,7 @@ async function uploadFileByChunks(
},
signal: AbortSignal,
): Promise<UploadFileResponseItem> {
const userStore = useUserStore()
const { chunkApiAuthToken } = storeToRefs(userStore)
const authToken = await ensureChunkApiAuthToken()
const { onProgress } = options
const fileHash = await fileHashHeadTailMiddle(file)
const fileSuffix = file.name.includes('.') ? file.name.slice(file.name.lastIndexOf('.')) : ''
......@@ -145,7 +159,7 @@ async function uploadFileByChunks(
fileSize: file.size,
fileSuffx: fileSuffix,
},
{ signal, headers: { authorization: chunkApiAuthToken.value } },
{ signal, headers: { authorization: authToken } },
)
// 秒传服务检查文件是否存在
......@@ -191,7 +205,7 @@ async function uploadFileByChunks(
signal,
headers: {
'Content-Type': 'multipart/form-data',
authorization: chunkApiAuthToken.value,
authorization: authToken,
},
})
......@@ -233,7 +247,7 @@ async function uploadFileByChunks(
signal,
headers: {
'Content-Type': 'multipart/form-data',
authorization: chunkApiAuthToken.value,
authorization: authToken,
},
},
)
......@@ -298,7 +312,7 @@ export const uploadFile = (
export const getTimestamp = async () => {
const {
data: { data: timestamp },
} = await axios.get(`${resolveUploadBaseUrl()}/api/auth/getTime`)
} = await axios.get(`${resolveUploadBaseUrl()}/api/auth/getTime`, { timeout: NAS_AUTH_TIMEOUT })
return timestamp
}
......@@ -308,10 +322,36 @@ export const getChunkApiAuthToken = async (weChatId: string) => {
const rawStr = CHUNK_API_AUTH_SECRET + timestamp
const {
data: { data },
} = await axios.post(`${resolveUploadBaseUrl()}/api/auth/getToken`, {
} = await axios.post(
`${resolveUploadBaseUrl()}/api/auth/getToken`,
{
timestamp,
weChatId,
secret: CryptoJS.MD5(rawStr).toString(),
})
},
{ timeout: NAS_AUTH_TIMEOUT },
)
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 @@
<script lang="ts" setup generic="T extends string | string[]">
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 { UploadFileProps } from './types'
import { push } from 'notivue'
......@@ -156,7 +156,7 @@ const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) =>
}
} catch (error) {
console.error('上传失败:', error)
push.error('上传失败,请重试')
push.error(resolveUploadErrorMessage(error))
const fileIndex = fileList.value.findIndex((file) => file.uid === uid)
if (fileIndex !== -1) {
......
......@@ -83,7 +83,7 @@
</template>
<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 { UploadVideoProps } from './types'
import { getVideoMetadata } from '@/utils'
......@@ -275,7 +275,8 @@ const startUpload = async () => {
push.success('视频上传成功!')
} catch (error) {
uploading.value = false
uploadError.value = error instanceof Error ? error.message : '上传失败,请重试'
console.error('视频上传失败', error)
uploadError.value = resolveUploadErrorMessage(error)
push.error(uploadError.value)
} finally {
if (localVideoUrl) {
......
......@@ -41,6 +41,13 @@ app.directive('parse-comment', vParseComment)
// 全局组件挂载
app.component('SvgIcon', SvgIcon)
router.isReady().then(() => {
// 首次导航的守卫一旦抛错 isReady 会 reject 页面就永远挂不上去(整屏白屏)
// 所以这里兜底 无论首次导航成功与否都要把应用挂载出来
router
.isReady()
.catch((error) => {
console.error('首次路由导航失败 仍然挂载应用', error)
})
.then(() => {
app.mount('#app')
})
})
......@@ -4,6 +4,7 @@ import { saveScrollPosition } from './scrollStorage'
import { parseCode } from '@/utils/wxUtil'
import { useUserStore } from '@/stores'
import { finishPageProgress, startPageProgress } from '@/components/common/Progress/usePageProgress'
import { push } from 'notivue'
// 白名单
const WHITE_LIST: string[] = ['/aa']
......@@ -30,10 +31,17 @@ export function registerRouterGuards(router: Router) {
if (code) {
// 如果有token 就不需要重新获取用户信息
// if (!userStore.token || !userStore.refreshToken) {
try {
await userStore.getUserInfoByCode({
code,
isCodeLogin: 0,
})
} catch (error) {
// 守卫里抛错会直接中断导航 页面白屏 什么提示都没有
// 这里兜住 给个提示再照常进页面 后续接口 401 会走重新登录的逻辑
console.error('企微 code 登录失败', error)
push.error('登录失败,请刷新页面重试')
}
// }
return {
path: to.path,
......
......@@ -45,8 +45,15 @@ export const useUserStore = defineStore('user', () => {
setUserInfoAndToken(data)
// 同时获取关于分片的token
const token = await getChunkApiAuthToken(data.account)
setChunkApiAuthToken(token)
// 这个 token 走的是 nas 那边的接口 跟登录、跟首页展示都没有依赖关系
// 所以不 await 直接放后台跑 让首页尽快出来 nas 慢或者挂了也不会拖住登录
// 这里的 catch 不能省 不然 nas 挂掉时会变成未处理的 promise rejection
// 万一没拿到 等真正要分片上传的时候会再补一次(见 ensureChunkApiAuthToken)
getChunkApiAuthToken(data.account)
.then(setChunkApiAuthToken)
.catch((error) => {
console.error('获取分片上传 token 失败 不影响登录', error)
})
}
const getNewToken = async () => {
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