diff --git a/README.md b/README.md index b9a03e9..5d9b525 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ npm run build:app - 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。 - 排车与出库写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。 - 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。 -- 登录、恢复和刷新会 fail-closed 校验统一 Token 的平台操作主体和冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发 401 共享一次 refresh,主体与绑定仓保持一致后原子替换统一 Token 和权限快照,并将原请求或照片上传重放一次。 +- 登录、恢复和刷新会 fail-closed 校验统一 Token 的平台操作主体和冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发 401 共享一次 refresh,主体与绑定仓保持一致后原子替换统一 Token 和权限快照,并将原请求或照片上传重放一次。刷新失败或重放后仍未授权时清空会话和作业草稿,并返回登录页;退出或重新登录后,旧请求不能重放到新会话,迟到的刷新响应也不能恢复旧会话或清除新会话。 - 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询均已接入 `/api/pda/**` 专用接口;支持 PDA 广播扫码与 H5 人工输入,并按服务端版本、幂等键和冲突状态恢复,不调用平台、客户或内部接口。 - 本地契约测试与 H5/App 资源编译不等于真实测试环境 HTTP 或 PDA 真机验收。 diff --git a/src/api/overseas.js b/src/api/overseas.js index 19b049e..3966a6f 100644 --- a/src/api/overseas.js +++ b/src/api/overseas.js @@ -1,7 +1,18 @@ import { refresh } from './auth.js' -import { session, replaceTokens, signOut } from '../stores/session.js' +import { session, replaceTokens, signOut, getSessionRevision } from '../stores/session.js' let refreshPromise = null +let refreshRevision = -1 + +function requireCurrentSession(revision) { + if (revision !== getSessionRevision()) throw new Error('登录会话已变更,请重新操作') +} + +function expireSession(revision) { + if (revision !== getSessionRevision()) return + signOut() + uni.reLaunch({ url: '/pages/login/login' }) +} function errorFrom(response, fallback) { const body = response?.data || {} @@ -21,23 +32,27 @@ function successful(response, body) { return response?.statusCode === 200 && ['00000', '200'].includes(String(body?.code)) } -async function refreshOverseasToken() { +async function refreshOverseasToken(revision) { + requireCurrentSession(revision) if (!session.refreshToken) { - signOut() + expireSession(revision) throw new Error('登录状态已过期,请重新登录') } - if (!refreshPromise) { - refreshPromise = refresh(session.refreshToken) - .then((tokens) => { replaceTokens(tokens); return session.token }) - .catch((error) => { signOut(); throw error }) - .finally(() => { refreshPromise = null }) + if (!refreshPromise || refreshRevision !== revision) { + refreshRevision = revision + const pending = refresh(session.refreshToken) + .then((tokens) => { requireCurrentSession(revision); replaceTokens(tokens); return session.token }) + .catch((error) => { expireSession(revision); throw error }) + .finally(() => { if (refreshPromise === pending) refreshPromise = null }) + refreshPromise = pending } return refreshPromise } -async function tokenAfterUnauthorized(failedToken) { +async function tokenAfterUnauthorized(failedToken, revision) { + requireCurrentSession(revision) if (session.token && session.token !== failedToken) return session.token - return refreshOverseasToken() + return refreshOverseasToken(revision) } function overseasUrl(path) { @@ -70,13 +85,17 @@ function rawRequest(method, path, data, token) { } export async function overseasRequest(method, path, data, retry = true) { + const revision = getSessionRevision() console.info('[Overseas PDA API] request', { method, path }) const attemptedToken = session.token let response = await rawRequest(method, path, data, attemptedToken) + requireCurrentSession(revision) if (retry && unauthorized(response)) { - const replacementToken = await tokenAfterUnauthorized(attemptedToken) + const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision) response = await rawRequest(method, path, data, replacementToken) } + requireCurrentSession(revision) + if (unauthorized(response)) expireSession(revision) const body = response?.data || {} console.info('[Overseas PDA API] response', { method, path, statusCode: response?.statusCode, code: body?.code, message: body?.message, requestId: body?.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id'], @@ -149,27 +168,35 @@ function rawDownload(path, token) { } export async function overseasUpload(path, filePath, name = 'file', retry = true) { + const revision = getSessionRevision() const attemptedToken = session.token let response = await rawUpload(path, filePath, name, attemptedToken) + requireCurrentSession(revision) let body = null try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ } if (retry && unauthorized({ ...response, data: body })) { - const replacementToken = await tokenAfterUnauthorized(attemptedToken) + const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision) response = await rawUpload(path, filePath, name, replacementToken) try { body = JSON.parse(response.data || '{}') } catch (_) { body = null } } + requireCurrentSession(revision) + if (unauthorized({ ...response, data: body })) expireSession(revision) if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '海外作业文件上传失败') return body.data } export async function overseasDownload(path, options = {}) { + const revision = getSessionRevision() const { retry = true, expectedContentTypePrefix = '' } = options const attemptedToken = session.token let response = await rawDownload(path, attemptedToken) + requireCurrentSession(revision) if (retry && unauthorized(response)) { - const replacementToken = await tokenAfterUnauthorized(attemptedToken) + const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision) response = await rawDownload(path, replacementToken) } + requireCurrentSession(revision) + if (unauthorized(response)) expireSession(revision) if (response?.statusCode !== 200 || !response?.tempFilePath) { throw errorFrom({ ...response, data: {} }, '签收照片下载失败') } diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue index 7af3466..31dbb60 100644 --- a/src/pages/dispatch/dispatch.vue +++ b/src/pages/dispatch/dispatch.vue @@ -26,12 +26,12 @@