fix(pda): leave expired sessions and clarify dispatch navigation

This commit is contained in:
2026-10-03 06:49:54 +08:00
parent 28a046c37f
commit 3990074352
5 changed files with 133 additions and 17 deletions
+1 -1
View File
@@ -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
View File
@@ -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: {} }, '签收照片下载失败')
}
+2 -2
View File
@@ -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
View File
@@ -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 = ''
+85 -1
View File
@@ -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 = []