fix(pda): leave expired sessions and clarify dispatch navigation
This commit is contained in:
@@ -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 真机验收。
|
||||
|
||||
|
||||
+40
-13
@@ -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: {} }, '签收照片下载失败')
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
</view>
|
||||
|
||||
<template v-if="!loading && !current">
|
||||
<view class="list-toolbar"><view><text class="list-title">待处理排车任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
<view class="list-toolbar"><view><text class="list-title">排车任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
<view v-if="tasks.length" class="task-list">
|
||||
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)">
|
||||
<view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view>
|
||||
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · 车牌 {{ task.vehiclePlate || '—' }}</text>
|
||||
<text class="task-enter">查看并开始拣货 ›</text>
|
||||
<text class="task-enter">查看任务详情 ›</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无排车拣货任务</text><text>可刷新列表,或直接扫描排车单号恢复作业。</text></view>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { resetFulfillmentSession } from './fulfillment.js'
|
||||
import { resetWarehouseWork } from './warehouse-work.js'
|
||||
|
||||
const SESSION_KEY = 'overseas-pda-auth-session'
|
||||
let sessionRevision = 0
|
||||
export function getSessionRevision() { return sessionRevision }
|
||||
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
|
||||
export const PDA_AUTHORITY = 'lastleg:pda:operate'
|
||||
|
||||
@@ -108,6 +110,7 @@ function persistSession() {
|
||||
}
|
||||
|
||||
export function signIn(account, warehouse, options = {}) {
|
||||
sessionRevision += 1
|
||||
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse,
|
||||
warehouseId: account.warehouseId || null, warehouseCode: account.warehouseCode || '', warehouseName: account.warehouseName || '' }
|
||||
session.token = options.token || account.token || ''
|
||||
@@ -184,6 +187,7 @@ export function restoreSession() {
|
||||
currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.claims, 'tenant_id'),
|
||||
apiBase: API_ROOT, authorization: pair.authorization
|
||||
})
|
||||
sessionRevision += 1
|
||||
return true
|
||||
} catch (_) {
|
||||
signOut()
|
||||
@@ -192,6 +196,7 @@ export function restoreSession() {
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
sessionRevision += 1
|
||||
session.currentUser = null
|
||||
session.token = ''
|
||||
session.refreshToken = ''
|
||||
|
||||
@@ -22,7 +22,8 @@ function tokens(version = 'old') {
|
||||
|
||||
function prepareSession() {
|
||||
globalThis.uni = {
|
||||
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {}
|
||||
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {},
|
||||
launches: [], reLaunch(options) { this.launches.push(options.url) }
|
||||
}
|
||||
resetOverseasRequestStateForTest(); signOut()
|
||||
const value = tokens('old')
|
||||
@@ -114,6 +115,8 @@ test('request retries only once and preserves the final request and trace identi
|
||||
})
|
||||
assert.equal(refreshCount, 1)
|
||||
assert.equal(businessCalls, 2)
|
||||
assert.equal(session.token, '')
|
||||
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
||||
})
|
||||
|
||||
test('refresh rejection clears every local token and returns the auth trace', async () => {
|
||||
@@ -133,8 +136,89 @@ test('refresh rejection clears every local token and returns the auth trace', as
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.equal(session.token, '')
|
||||
assert.equal(session.refreshToken, '')
|
||||
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
||||
})
|
||||
|
||||
test('concurrent refresh rejection clears the session and navigates to login only once', async () => {
|
||||
prepareSession()
|
||||
let refreshCount = 0
|
||||
uni.request = (options) => {
|
||||
if (options.url.endsWith('/auth/refresh')) {
|
||||
refreshCount += 1
|
||||
return setTimeout(() => options.success(denied()), 5)
|
||||
}
|
||||
options.success(denied())
|
||||
}
|
||||
const results = await Promise.allSettled([overseasRequest('GET', '/one'), overseasRequest('GET', '/two')])
|
||||
assert.ok(results.every(result => result.status === 'rejected'))
|
||||
assert.equal(refreshCount, 1)
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
||||
})
|
||||
|
||||
for (const refreshSucceeds of [true, false]) {
|
||||
test(`late refresh ${refreshSucceeds ? 'success' : 'failure'} cannot restore logout or clear a new login`, async () => {
|
||||
for (const relogin of [false, true]) {
|
||||
prepareSession()
|
||||
let completeRefresh
|
||||
let started
|
||||
const refreshStarted = new Promise(resolve => { started = resolve })
|
||||
let businessCalls = 0
|
||||
uni.request = (options) => {
|
||||
if (options.url.endsWith('/auth/refresh')) {
|
||||
completeRefresh = () => options.success(refreshSucceeds ? ok(tokens('late')) : denied())
|
||||
started()
|
||||
return
|
||||
}
|
||||
businessCalls += 1
|
||||
options.success(denied())
|
||||
}
|
||||
const pending = overseasRequest('POST', '/write')
|
||||
await refreshStarted
|
||||
signOut()
|
||||
const replacement = tokens('new-login')
|
||||
if (relogin) signInWithTokens(replacement)
|
||||
completeRefresh()
|
||||
await assert.rejects(pending)
|
||||
assert.equal(session.token, relogin ? replacement.accessToken : '')
|
||||
assert.equal(businessCalls, 1)
|
||||
assert.deepEqual(uni.launches, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('an old business response cannot replay with a newly logged-in session', async () => {
|
||||
prepareSession()
|
||||
let complete
|
||||
let calls = 0
|
||||
uni.request = options => { calls += 1; complete = () => options.success(denied()) }
|
||||
const pending = overseasRequest('POST', '/write')
|
||||
signOut()
|
||||
const replacement = tokens('another-login')
|
||||
signInWithTokens(replacement)
|
||||
complete()
|
||||
await assert.rejects(pending, /会话已变更/)
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(session.token, replacement.accessToken)
|
||||
assert.deepEqual(uni.launches, [])
|
||||
})
|
||||
|
||||
for (const kind of ['upload', 'download']) {
|
||||
test(`${kind} final unauthorized response navigates once and stops after one replay`, async () => {
|
||||
prepareSession()
|
||||
let refreshCount = 0
|
||||
let calls = 0
|
||||
uni.request = options => { refreshCount += 1; options.success(ok(tokens(kind))) }
|
||||
uni.uploadFile = options => { calls += 1; options.success({ ...denied(), data: JSON.stringify(denied().data) }) }
|
||||
uni.downloadFile = options => { calls += 1; options.success(denied()) }
|
||||
await assert.rejects(kind === 'upload' ? overseasUpload('/photo', '/tmp/photo.jpg') : overseasDownload('/photo'))
|
||||
assert.equal(refreshCount, 1)
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
||||
})
|
||||
}
|
||||
|
||||
test('photo upload refreshes and replays once while retaining the file payload', async () => {
|
||||
const old = prepareSession(); const next = tokens('upload')
|
||||
let refreshCount = 0; const uploads = []
|
||||
|
||||
Reference in New Issue
Block a user