diff --git a/.env.example b/.env.example index a227b2d..5186d59 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,8 @@ -VITE_AUTH_API_BASE_URL=http://127.0.0.1:9000/api -VITE_OVERSEAS_API_BASE_URL=http://127.0.0.1:9300/api +VITE_API_BASE_URL=http://127.0.0.1:9000/api # H5 同源前缀,由 Vite 代理转发;App 构建使用上面的绝对域名直连。 -VITE_H5_AUTH_API_BASE_URL=/api -VITE_H5_OVERSEAS_API_BASE_URL=/overseas-api +VITE_H5_API_BASE_URL=/api # H5 开发代理上游;正式 App 构建请在本机环境文件中改为可访问的绝对 API 根地址。 API_PROXY_TARGET=http://127.0.0.1:9000 -OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300 -# 上游本身以 /overseas-api 对外代理时设为 true;直连 gateway-overseas 时保持 false。 -OVERSEAS_API_PROXY_PRESERVE_PREFIX=false # 海外 PDA 独立更新清单地址;不得与国内 PDA 发布记录混用。 VITE_PDA_UPDATE_URL=https://updates.example.com/pda/overseas/latest # 仅开发服务器自动预填;请在不提交的 .env.local 中填写真实开发账号。 diff --git a/AGENTS.md b/AGENTS.md index 328dc2e..7b7fffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ ## 快速读取顺序 1. 先用“页面与能力快速索引”定位 `src/pages.json`、工作台或最小页面;页面业务配置从 `src/data/overseas-workbench.js` 进入,不扫描 `src/uni_modules/`。 -2. 登录、Token 或双区域请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/跨区域认证与服务通信契约.md`。 -3. 沿实际 API 定位:入仓/库存契约读 `../../baiyue-firstleg/lastleg-service/AGENTS.md`;资源契约读 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径或鉴权问题才读 `../../baiyue-firstleg/gateway-overseas/AGENTS.md`。跨模块流程或入口不明才读 `../../docs/海外仓业务/README.md`;纯页面改动不自动读取后端指引。 +2. 登录、Token 或统一入口请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/统一认证与服务通信契约.md`。 +3. 沿实际 API 定位:入仓/库存契约读 `../../baiyue-firstleg/lastleg-service/AGENTS.md`;资源契约读 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径或鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块流程或入口不明才读 `../../docs/海外仓业务/README.md`;纯页面改动不自动读取后端指引。 最小读取集按任务保持在本文件、目标页面/API 和一个直接服务指引;只有跨服务状态或鉴权无法判断时才增加第二个服务指引。累计超过约 250 行时优先读取命中段,不全文加载海外仓 README。 @@ -16,9 +16,9 @@ ## 服务与发布边界 -- 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。 -- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate` 只从 Auth 登录/刷新响应的顶层权限快照读取并持久化,不得解析 JWT 权限。权限快照缺失、无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。 -- 并发海外 401 只允许一次国内 refresh;双 Token 完整校验后原子替换,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。 +- Auth 是唯一账号和 Token 签发方;认证和业务请求共用统一 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 使用 VITE_H5_API_BASE_URL。 +- 登录、刷新和本地会话必须校验统一 Token 的平台主体、成员、有效期和冻结海外仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate` 只从 Auth 登录/刷新响应的顶层权限快照读取并持久化,不得解析 JWT 权限。权限快照缺失、无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。 +- 并发 401 只允许一次 refresh;统一 Token 和权限快照完整校验后原子替换,主体或绑定仓改变时清理会话并重新登录,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。 - 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。 - `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。 - 环境地址只通过 `.env.local` 等未提交环境文件配置,不写入源码。 diff --git a/src/api/overseas.js b/src/api/overseas.js index 3282d7e..363e0bc 100644 --- a/src/api/overseas.js +++ b/src/api/overseas.js @@ -45,12 +45,12 @@ function overseasUrl(path) { const nativeRuntime = typeof plus !== 'undefined' || (typeof location !== 'undefined' && location.protocol === 'file:') if (nativeRuntime && !/^https?:\/\//i.test(base)) { - const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_OVERSEAS_API_BASE_URL') - error.code = 'PDA_OVERSEAS_API_BASE_REQUIRED' + const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_API_BASE_URL') + error.code = 'PDA_API_BASE_REQUIRED' throw error } const value = `/${String(path || '').replace(/^\/+/, '')}` - const normalized = /\/(?:api|overseas-api)$/.test(base) + const normalized = /\/api$/.test(base) ? value.replace(/^\/api(?=\/)/, '') : value return `${base}${normalized}` diff --git a/src/config/runtime.js b/src/config/runtime.js index 6ab17d4..05aa5e3 100644 --- a/src/config/runtime.js +++ b/src/config/runtime.js @@ -1,31 +1,14 @@ const runtimeEnvironment = import.meta.env || {} -const configuredAuthApiRoot = runtimeEnvironment.VITE_AUTH_API_BASE_URL || '' -const configuredOverseasApiRoot = runtimeEnvironment.VITE_OVERSEAS_API_BASE_URL || '' +const configuredApiRoot = runtimeEnvironment.VITE_API_BASE_URL || '' const development = Boolean(runtimeEnvironment.DEV) - -let selectedAuthApiRoot = configuredAuthApiRoot || '/api' -let selectedOverseasApiRoot = configuredOverseasApiRoot || '/overseas-api' +let selectedApiRoot = configuredApiRoot || '/api' +// #ifdef H5 +selectedApiRoot = runtimeEnvironment.VITE_H5_API_BASE_URL || '/api' +// #endif const nativeRuntime = typeof plus !== 'undefined' || (typeof location !== 'undefined' && location.protocol === 'file:') - -// H5 must stay same-origin and use Vite's CORS-safe proxy; App builds keep the absolute API roots above. -// #ifdef H5 -selectedAuthApiRoot = runtimeEnvironment.VITE_H5_AUTH_API_BASE_URL || '/api' -selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/overseas-api' -// #endif - -// HBuilderX/App 基座 can expose a file:// location; never allow a relative API -// root there, even if a stale H5 bundle or compiler flag was reused. -if (nativeRuntime) { - selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot - // Native file:// builds cannot use the H5-only /overseas-api Vite proxy. - // Keep the missing value explicit so the request layer can report a useful - // configuration error instead of surfacing an opaque uploadFile:fail. - selectedOverseasApiRoot = configuredOverseasApiRoot || '' -} - -export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '') -export const OVERSEAS_API_ROOT = String(selectedOverseasApiRoot).replace(/\/$/, '') +if (nativeRuntime) selectedApiRoot = configuredApiRoot || '' +export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '') export const DEV_LOGIN = Object.freeze({ username: development ? String(runtimeEnvironment.VITE_PDA_DEV_USERNAME || '') : '', password: development ? String(runtimeEnvironment.VITE_PDA_DEV_PASSWORD || '') : '' diff --git a/src/stores/session.js b/src/stores/session.js index b82e2d0..e75cbba 100644 --- a/src/stores/session.js +++ b/src/stores/session.js @@ -1,5 +1,5 @@ import { reactive } from 'vue' -import { OVERSEAS_API_ROOT } from '../config/runtime.js' +import { API_ROOT } from '../config/runtime.js' import { resetReceivingSession } from './receiving.js' import { resetFulfillmentSession } from './fulfillment.js' import { resetWarehouseWork } from './warehouse-work.js' @@ -9,7 +9,7 @@ export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' export const PDA_AUTHORITY = 'lastleg:pda:operate' export const session = reactive({ - currentUser: null, token: '', domesticToken: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT, + currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: API_ROOT, authorization: { roles: [], authorities: [] } }) @@ -47,7 +47,7 @@ function includesClaim(values, expected) { } function requireActiveClaims(claims, label) { - if (claims.exp && Number(claims.exp) * 1000 <= Date.now()) throw new Error(`${label}已过期`) + if (!Number.isFinite(Number(claims.exp)) || Number(claims.exp) * 1000 <= Date.now()) throw new Error(`${label}已过期`) if (claims.app_code !== 'PLATFORM' || claimId(claims, 'tenant_id') !== '0') { throw new Error('当前账号不属于平台仓库作业端') } @@ -67,25 +67,20 @@ function requireAuthorizationSnapshot(authorization) { return { roles, authorities } } -export function validatePdaTokenPair(accessToken, overseasAccessToken, authorization) { - const domestic = decodeAccessToken(accessToken) - const overseas = decodeAccessToken(overseasAccessToken) - requireActiveClaims(domestic, '登录状态') - requireActiveClaims(overseas, '海外作业令牌') - for (const claim of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) { - if (claimId(domestic, claim) !== claimId(overseas, claim)) throw new Error('国内与海外令牌作业主体不一致') - } - const warehouseId = claimId(overseas, 'overseas_warehouse_id') +export function validatePdaToken(accessToken, authorization) { + const claims = decodeAccessToken(accessToken) + requireActiveClaims(claims, '登录状态') + const warehouseId = claimId(claims, 'overseas_warehouse_id') if (!/^\d+$/.test(warehouseId) || warehouseId === '0') throw new Error('海外PDA账号未绑定有效作业仓库') - return { domestic, overseas, warehouseId, authorization: requireAuthorizationSnapshot(authorization) } + return { claims, warehouseId, authorization: requireAuthorizationSnapshot(authorization) } } function userFromClaims(pair, saved = {}) { const savedWarehouseId = String(saved?.warehouseId || '') const sameWarehouse = savedWarehouseId === pair.warehouseId return { - id: claimId(pair.domestic, 'user_id'), - name: pair.domestic.username || '仓库作业员', + id: claimId(pair.claims, 'user_id'), + name: pair.claims.username || '仓库作业员', role: PDA_ROLE, roles: [...pair.authorization.roles], warehouse: sameWarehouse && saved.warehouseName ? saved.warehouseName : '', @@ -102,9 +97,9 @@ function storageAvailable() { function persistSession() { if (!storageAvailable()) return uni.setStorageSync(SESSION_KEY, { + version: 2, currentUser: session.currentUser, token: session.token, - domesticToken: session.domesticToken, refreshToken: session.refreshToken, tenantId: session.tenantId, apiBase: session.apiBase, @@ -116,10 +111,9 @@ export function signIn(account, warehouse, options = {}) { 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 || '' - session.domesticToken = options.domesticToken || account.domesticToken || '' session.refreshToken = options.refreshToken || account.refreshToken || '' session.tenantId = options.tenantId || account.tenantId || '' - session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT + session.apiBase = options.apiBase || account.apiBase || API_ROOT session.authorization = { roles: [...(options.authorization?.roles || [])], authorities: [...(options.authorization?.authorities || [])] @@ -130,28 +124,26 @@ export function signIn(account, warehouse, options = {}) { export function signInWithTokens(tokens) { if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') - if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken') - const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens) + const pair = validatePdaToken(tokens.accessToken, tokens) const user = userFromClaims(pair) return signIn(user, user.warehouse, { - token: tokens.overseasAccessToken, - domesticToken: tokens.accessToken, + token: tokens.accessToken, refreshToken: tokens.refreshToken || '', - tenantId: claimId(pair.domestic, 'tenant_id'), + tenantId: claimId(pair.claims, 'tenant_id'), authorization: pair.authorization }) } export function replaceTokens(tokens) { - if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌') - const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens) + if (!tokens?.accessToken) throw new Error('刷新响应缺少访问令牌') + const pair = validatePdaToken(tokens.accessToken, tokens) + if (session.currentUser && (String(session.currentUser.id) !== claimId(pair.claims, 'user_id') || String(session.currentUser.warehouseId) !== pair.warehouseId)) throw new Error('作业主体或绑定仓库已变更,请重新登录') const user = userFromClaims(pair, session.currentUser) const nextRefreshToken = tokens.refreshToken || session.refreshToken // Validate the complete replacement before mutating reactive or persisted session state. - session.domesticToken = tokens.accessToken - session.token = tokens.overseasAccessToken + session.token = tokens.accessToken session.refreshToken = nextRefreshToken - session.tenantId = claimId(pair.domestic, 'tenant_id') + session.tenantId = claimId(pair.claims, 'tenant_id') session.currentUser = user session.authorization = pair.authorization persistSession() @@ -174,15 +166,16 @@ export function applyWarehouseProfile(profile) { export function restoreSession() { if (!storageAvailable()) return false const saved = uni.getStorageSync(SESSION_KEY) - if (!saved?.token || !saved?.domesticToken || !saved?.refreshToken || !saved?.currentUser) { + if (!saved?.token || !saved?.refreshToken || !saved?.currentUser || saved.version !== 2) { signOut() return false } try { - const pair = validatePdaTokenPair(saved.domesticToken, saved.token, saved.authorization) + const pair = validatePdaToken(saved.token, saved.authorization) + if (String(saved.currentUser.id) !== claimId(pair.claims, 'user_id') || String(saved.currentUser.warehouseId) !== pair.warehouseId) throw new Error('缓存作业主体不一致') Object.assign(session, saved, { - currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.domestic, 'tenant_id'), - apiBase: OVERSEAS_API_ROOT, authorization: pair.authorization + currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.claims, 'tenant_id'), + apiBase: API_ROOT, authorization: pair.authorization }) return true } catch (_) { @@ -194,10 +187,9 @@ export function restoreSession() { export function signOut() { session.currentUser = null session.token = '' - session.domesticToken = '' session.refreshToken = '' session.tenantId = '' - session.apiBase = OVERSEAS_API_ROOT + session.apiBase = API_ROOT session.authorization = { roles: [], authorities: [] } if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY) resetReceivingSession() diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs index 068f965..0a42e65 100644 --- a/tests/fulfillment-workflows.test.mjs +++ b/tests/fulfillment-workflows.test.mjs @@ -55,7 +55,7 @@ test('fulfillment API uses dedicated PDA routes and never sends customer or ware test('fulfillment API calls the frozen list detail pick complete and depart routes with exact payloads', async () => { const calls = [] - session.apiBase = '/overseas-api'; session.token = 'overseas-token' + session.apiBase = '/api'; session.token = 'overseas-token' globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }) diff --git a/tests/overseas-request.test.mjs b/tests/overseas-request.test.mjs index 24c2010..683e452 100644 --- a/tests/overseas-request.test.mjs +++ b/tests/overseas-request.test.mjs @@ -15,7 +15,6 @@ function tokens(version = 'old') { const base = { user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM', overseas_warehouse_id: 900, exp: future(), version } return { accessToken: jwt({ ...base, username: 'picker-42' }), - overseasAccessToken: jwt(base), refreshToken: `refresh-${version}`, roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] } @@ -43,7 +42,7 @@ test('H5 proxy and App API roots normalize request upload and download paths to globalThis.uni.uploadFile = (options) => { uploads.push(options.url); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '1' } }), header: {} }) } globalThis.uni.downloadFile = (options) => { downloads.push(options.url); options.success({ statusCode: 200, tempFilePath: '/tmp/photo.jpg', header: {} }) } - session.apiBase = '/overseas-api' + session.apiBase = '/api' await resolvePallet('PLT-1') await overseasUpload('/api/pda/v1/last-leg/photos/files', '/tmp/photo.jpg') await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download') @@ -54,15 +53,15 @@ test('H5 proxy and App API roots normalize request upload and download paths to await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download') assert.deepEqual(requests, [ - '/overseas-api/pda/v1/last-leg/pallet-operations/pallets/resolve', + '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', 'https://overseas.example.com/api/pda/v1/last-leg/pallet-operations/pallets/resolve' ]) assert.deepEqual(uploads, [ - '/overseas-api/pda/v1/last-leg/photos/files', + '/api/pda/v1/last-leg/photos/files', 'https://overseas.example.com/api/pda/v1/last-leg/photos/files' ]) assert.deepEqual(downloads, [ - '/overseas-api/pda/v1/last-leg/photos/files/1/download', + '/api/pda/v1/last-leg/photos/files/1/download', 'https://overseas.example.com/api/pda/v1/last-leg/photos/files/1/download' ]) for (const url of [...requests, ...uploads, ...downloads]) assert.doesNotMatch(url, /\/api\/api\//) @@ -76,11 +75,11 @@ test('concurrent old-token 401 responses share one refresh and each replay once refreshCount += 1 return setTimeout(() => options.success(ok(next)), 10) } - if (options.header.Authorization === `Bearer ${old.overseasAccessToken}`) { + if (options.header.Authorization === `Bearer ${old.accessToken}`) { oldCalls += 1 return setTimeout(() => options.success(denied()), oldCalls === 1 ? 0 : 30) } - assert.equal(options.header.Authorization, `Bearer ${next.overseasAccessToken}`) + assert.equal(options.header.Authorization, `Bearer ${next.accessToken}`) newCalls += 1 setTimeout(() => options.success(ok({ call: newCalls })), 0) } @@ -92,8 +91,7 @@ test('concurrent old-token 401 responses share one refresh and each replay once assert.equal(refreshCount, 1) assert.equal(oldCalls, 2) assert.equal(newCalls, 2) - assert.equal(session.token, next.overseasAccessToken) - assert.equal(session.domesticToken, next.accessToken) + assert.equal(session.token, next.accessToken) }) test('request retries only once and preserves the final request and trace identifiers', async () => { @@ -134,7 +132,6 @@ 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.domesticToken, '') assert.equal(session.refreshToken, '') }) @@ -148,7 +145,7 @@ test('photo upload refreshes and replays once while retaining the file payload', } globalThis.uni.uploadFile = (options) => { uploads.push({ filePath: options.filePath, name: options.name, authorization: options.header.Authorization }) - if (options.header.Authorization === `Bearer ${old.overseasAccessToken}`) { + if (options.header.Authorization === `Bearer ${old.accessToken}`) { return options.success({ statusCode: 401, data: JSON.stringify({ code: 'A0401', requestId: 'upload-old', traceId: 'upload-old-trace' }), header: {} }) } options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '99' } }), header: {} }) @@ -178,11 +175,11 @@ test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 }) } try { - session.apiBase = '/overseas-api' + session.apiBase = '/api' assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' }) assert.equal(uploadFileCalls, 0) - assert.equal(uploaded.url, '/overseas-api/pda/v1/last-leg/photos/files') - assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.overseasAccessToken}`) + assert.equal(uploaded.url, '/api/pda/v1/last-leg/photos/files') + assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.accessToken}`) assert.ok(uploaded.options.body instanceof FormData) } finally { globalThis.fetch = originalFetch @@ -203,14 +200,14 @@ test('H5 File photo is appended directly without rereading a temporary blob URL' let calls = 0 globalThis.uni.uploadFile = (options) => { calls += 1 - assert.equal(options.url, '/overseas-api/pda/photo') - assert.equal(options.header.Authorization, `Bearer ${value.overseasAccessToken}`) + assert.equal(options.url, '/api/pda/photo') + assert.equal(options.header.Authorization, `Bearer ${value.accessToken}`) assert.equal(options.files[0].file, selected) assert.equal(options.files[0].name, 'file') options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} }) } try { - session.apiBase = '/overseas-api' + session.apiBase = '/api' assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' }) assert.equal(calls, 1) } finally { @@ -225,13 +222,13 @@ test('native file runtime rejects an H5-relative overseas root before upload wit prepareSession() const originalLocation = globalThis.location globalThis.location = { protocol: 'file:' } - session.apiBase = '/overseas-api' + session.apiBase = '/api' let uploadCalls = 0 globalThis.uni.uploadFile = () => { uploadCalls += 1 } try { await assert.rejects(overseasUpload('/pda/photo', '_doc/photo.jpg'), (error) => { - assert.equal(error.code, 'PDA_OVERSEAS_API_BASE_REQUIRED') - assert.match(error.message, /VITE_OVERSEAS_API_BASE_URL/) + assert.equal(error.code, 'PDA_API_BASE_REQUIRED') + assert.match(error.message, /VITE_API_BASE_URL/) assert.match(error.message, /HTTP\(S\)/) return true }) @@ -270,8 +267,8 @@ test('authenticated photo download maps the public api path through the H5 overs } assert.equal(await overseasDownload('/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download'), '/tmp/restored.jpg') assert.equal(calls.length, 1) - assert.equal(calls[0].url, '/overseas-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download') - assert.equal(calls[0].header.Authorization, `Bearer ${value.overseasAccessToken}`) + assert.equal(calls[0].url, '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download') + assert.equal(calls[0].header.Authorization, `Bearer ${value.accessToken}`) }) test('native image download accepts a valid temp file when App-Plus omits response headers', async () => { diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs index 74a6c91..4b1d40b 100644 --- a/tests/project-contract.test.mjs +++ b/tests/project-contract.test.mjs @@ -14,9 +14,8 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity' assert.doesNotMatch(pages, /pages\/picking|pages\/tasks/) assert.match(pkg, /"build:app"\s*:\s*"uni build -p app"/) assert.match(vite, /API_PROXY_TARGET/) - assert.match(vite, /OVERSEAS_API_PROXY_TARGET/) - assert.match(vite, /\/overseas-api/) - assert.match(vite, /replace\(\/\^\\\/overseas-api\//) + assert.doesNotMatch(vite, /OVERSEAS_API_PROXY_TARGET|gateway-overseas/) + assert.match(vite, /\/api/) assert.match(vite, /removeHeader\('origin'\)/) assert.match(vite, /removeHeader\('referer'\)/) assert.match(vite, /__VUE_I18N_FULL_INSTALL__:\s*true/) @@ -35,7 +34,7 @@ test('overseas workbench contains confirmed warehouse and phase-five outbound do for (const label of ['入仓作业', '拆柜收货', '退货入库', '退货合箱', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label)) for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded)) assert.match(session, /overseas-pda-auth-session/) - assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/) + assert.match(runtime, /VITE_API_BASE_URL/) }) test('login has no committed demo credentials and inbound operation implements the locked container flow', async () => { @@ -49,9 +48,9 @@ test('login has no committed demo credentials and inbound operation implements t assert.match(auth, /authRequest\('\/auth\/login', \{ loginId, password \}\)/) assert.doesNotMatch(auth, /authRequest\('\/auth\/login', \{ username, password \}\)/) assert.match(runtime, /runtimeEnvironment\.DEV|VITE_PDA_DEV_USERNAME|VITE_PDA_DEV_PASSWORD/) - assert.match(runtime, /'\/overseas-api'/) - assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_AUTH_API_BASE_URL/) - assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_OVERSEAS_API_BASE_URL/) + assert.match(runtime, /'\/api'/) + assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/) + assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/) assert.match(operation, /startScanBroadcast/) assert.match(operation, /palletWorkflowActive = Boolean\(this\.activeInbound \|\| this\.resolvedPallet\)/) assert.match(operation, /queuedTarget: scan\.target/) diff --git a/tests/return-packing-api.test.mjs b/tests/return-packing-api.test.mjs index 0de4e23..412ff9f 100644 --- a/tests/return-packing-api.test.mjs +++ b/tests/return-packing-api.test.mjs @@ -13,16 +13,16 @@ test('packing preview keeps customer scope internal and exposes stable order ref test('return packing API uses exact PDA inventory routes without submitting customer or warehouse scope', async () => { const calls = [] - session.apiBase = '/overseas-api'; session.token = 'packing-token' + session.apiBase = '/api'; session.token = 'packing-token' globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }); let data = {}; if (options.url.includes('/packing/orders')) data = { customerId: '100', orders: [], skus: [] }; else if (options.url.endsWith('/cartons/merge')) data = { cartonMark: 'RC-1', skuCode: 'SKU-1', totalQuantity: 2, label: { cartonMark: 'RC-1', items: [] } }; else data = { cartonMark: 'RC/1', totalQuantity: 2, items: [] }; options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) } } await findPackingOrders('RT/1') const request = { returnNos: ['RT/1'], items: [{ skuCode: 'SKU-1', quantity: 2 }], length: 10, width: 20, height: 30, weight: 4, idempotencyKey: 'merge-key' } await mergeReturnCartons(request) await getReturnCartonLabel('RC/1') assert.deepEqual(calls.map((item) => item.url), [ - '/overseas-api/pda/v1/last-leg/return-inventory/packing/orders?returnNo=RT%2F1', - '/overseas-api/pda/v1/last-leg/return-inventory/cartons/merge', - '/overseas-api/pda/v1/last-leg/return-inventory/cartons/RC%2F1/label' + '/api/pda/v1/last-leg/return-inventory/packing/orders?returnNo=RT%2F1', + '/api/pda/v1/last-leg/return-inventory/cartons/merge', + '/api/pda/v1/last-leg/return-inventory/cartons/RC%2F1/label' ]) assert.deepEqual(calls[1].data, request) assert.ok(calls.every((item) => item.authorization === 'Bearer packing-token')) @@ -30,13 +30,13 @@ test('return packing API uses exact PDA inventory routes without submitting cust }) test('return packing lookup treats not found as an empty preview', async () => { - session.apiBase = '/overseas-api'; session.token = 'packing-token' + session.apiBase = '/api'; session.token = 'packing-token' globalThis.uni = { request(options) { options.success({ statusCode: 404, data: { code: 'A0404', message: '没有匹配的可合箱库存' }, header: {} }) } } assert.deepEqual(await findPackingOrders('MISSING-RETURN'), { customerKey: '', orders: [], skus: [] }) }) test('return packing lookup still rejects real service errors', async () => { - session.apiBase = '/overseas-api'; session.token = 'packing-token' + session.apiBase = '/api'; session.token = 'packing-token' globalThis.uni = { request(options) { options.success({ statusCode: 500, data: { code: 'B0500', message: '服务暂不可用' }, header: {} }) } } await assert.rejects(() => findPackingOrders('RT-1'), (error) => error.code === 'B0500') }) diff --git a/tests/return-receipt-api.test.mjs b/tests/return-receipt-api.test.mjs index 008b52d..432450e 100644 --- a/tests/return-receipt-api.test.mjs +++ b/tests/return-receipt-api.test.mjs @@ -24,7 +24,7 @@ test('confirmed receipt normalization preserves generated carton and batch refer test('return receipt API calls only the dedicated PDA routes and preserves the receipt payload', async () => { const calls = [] - session.apiBase = '/overseas-api'; session.token = 'return-token' + session.apiBase = '/api'; session.token = 'return-token' globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }) @@ -40,9 +40,9 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' } await confirmReturnReceipt(confirm) assert.deepEqual(calls.map((item) => item.url), [ - '/overseas-api/pda/v1/last-leg/return-receipts/start', - '/overseas-api/pda/v1/last-leg/return-receipts/sku-candidates', - '/overseas-api/pda/v1/last-leg/return-receipts/confirm' + '/api/pda/v1/last-leg/return-receipts/start', + '/api/pda/v1/last-leg/return-receipts/sku-candidates', + '/api/pda/v1/last-leg/return-receipts/confirm' ]) assert.deepEqual(calls[0].data, { returnNo: 'RT-001' }) assert.deepEqual(calls[1].data, { skuCode: 'SKU-1' }) @@ -52,7 +52,7 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r }) test('return receipt lookup treats A0404 and HTTP 404 as empty results', async () => { - session.apiBase = '/overseas-api'; session.token = 'return-token' + session.apiBase = '/api'; session.token = 'return-token' let requestCount = 0 globalThis.uni = { request(options) { @@ -66,7 +66,7 @@ test('return receipt lookup treats A0404 and HTTP 404 as empty results', async ( }) test('return receipt lookup still rejects real service errors', async () => { - session.apiBase = '/overseas-api'; session.token = 'return-token' + session.apiBase = '/api'; session.token = 'return-token' globalThis.uni = { request(options) { options.success({ statusCode: 500, data: { code: 'B0500', message: '服务暂不可用' }, header: {} }) diff --git a/tests/session-auth.test.mjs b/tests/session-auth.test.mjs index 07431c6..5013829 100644 --- a/tests/session-auth.test.mjs +++ b/tests/session-auth.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { PDA_AUTHORITY, PDA_ROLE, applyWarehouseProfile, replaceTokens, restoreSession, session, - decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair + decodeAccessToken, signInWithTokens, signOut, validatePdaToken } from '../src/stores/session.js' import { receiving } from '../src/stores/receiving.js' import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js' @@ -24,9 +24,9 @@ function tokenPair(overrides = {}) { overseas_warehouse_id: 900, exp: future() } const domestic = { ...base, username: 'picker-42', ...(overrides.domestic || {}) } - const overseas = { ...base, ...(overrides.overseas || {}) } + return { - accessToken: jwt(domestic), overseasAccessToken: jwt(overseas), + accessToken: jwt(domestic), refreshToken: overrides.refreshToken ?? 'refresh-42', roles: Object.hasOwn(overrides, 'roles') ? overrides.roles : [PDA_ROLE], authorities: Object.hasOwn(overrides, 'authorities') ? overrides.authorities : [PDA_AUTHORITY] @@ -46,17 +46,17 @@ function storage() { test('valid platform warehouse picker tokens freeze the same operator and warehouse', () => { storage(); signOut() const tokens = tokenPair() - const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens) + const pair = validatePdaToken(tokens.accessToken, tokens) assert.equal(pair.warehouseId, '900') const user = signInWithTokens(tokens) assert.deepEqual(user.roles, [PDA_ROLE]) assert.equal(user.warehouseId, '900') assert.equal(user.warehouse, '') assert.equal(session.tenantId, '0') - assert.equal(session.token, tokens.overseasAccessToken) + assert.equal(session.token, tokens.accessToken) assert.deepEqual(session.authorization, { roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] }) - assert.equal(decodeAccessToken(tokens.overseasAccessToken).roles, undefined) - assert.equal(decodeAccessToken(tokens.overseasAccessToken).authorities, undefined) + assert.equal(decodeAccessToken(tokens.accessToken).roles, undefined) + assert.equal(decodeAccessToken(tokens.accessToken).authorities, undefined) }) test('JWT decoding preserves unquoted snowflake subjects and warehouse IDs exactly', () => { @@ -81,11 +81,11 @@ test('login fails closed for wrong role, authority, warehouse, application, or t tokenPair({ authorities: [] }), tokenPair({ roles: null }), tokenPair({ authorities: null }), - tokenPair({ overseas: { overseas_warehouse_id: null } }), + tokenPair({ domestic: { overseas_warehouse_id: null } }), tokenPair({ domestic: { app_code: 'MERCHANT', tenant_id: 100 } }), - tokenPair({ overseas: { user_id: 99 } }), - tokenPair({ overseas: { membership_id: 99 } }), - tokenPair({ overseas: { overseas_warehouse_id: 901 } }) + tokenPair({ domestic: { user_id: null } }), + tokenPair({ domestic: { membership_id: null } }), + tokenPair({ domestic: { exp: null } }) ] for (const tokens of invalidPairs) { assert.throws(() => signInWithTokens(tokens)) @@ -93,22 +93,40 @@ test('login fails closed for wrong role, authority, warehouse, application, or t } }) -test('refresh replacement validates both tokens before atomically changing session', () => { +test('refresh replacement validates the unified token before atomically changing session', () => { storage(); signOut() const original = tokenPair() signInWithTokens(original) - const before = { domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken } + const before = { access: session.token, refresh: session.refreshToken } const invalid = tokenPair({ authorities: [], refreshToken: 'invalid-refresh' }) assert.throws(() => replaceTokens(invalid), /lastleg:pda:operate/) - assert.deepEqual({ domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken }, before) + assert.deepEqual({ access: session.token, refresh: session.refreshToken }, before) const replacement = tokenPair({ refreshToken: 'refresh-43' }) replaceTokens(replacement) - assert.equal(session.domesticToken, replacement.accessToken) - assert.equal(session.token, replacement.overseasAccessToken) + assert.equal(session.token, replacement.accessToken) assert.equal(session.refreshToken, 'refresh-43') }) +test('refresh cannot carry an old operation into another user or warehouse', () => { + storage(); signOut(); signInWithTokens(tokenPair()) + const original = session.token + for (const domestic of [{ user_id: 99 }, { overseas_warehouse_id: 901 }]) { + assert.throws(() => replaceTokens(tokenPair({ domestic })), /主体或绑定仓库已变更/) + assert.equal(session.token, original) + assert.equal(session.currentUser.warehouseId, '900') + } +}) + +test('legacy dual-token storage requires a fresh login', () => { + const values = storage(); signOut(); signInWithTokens(tokenPair()) + const saved = structuredClone([...values.values()][0]) + delete saved.version + signOut(); values.set('overseas-pda-auth-session', saved) + assert.equal(restoreSession(), false) + assert.equal(session.currentUser, null) +}) + test('restore rebuilds identity from claims and removes expired or tampered storage', () => { const values = storage(); signOut() signInWithTokens(tokenPair()) @@ -123,14 +141,14 @@ test('restore rebuilds identity from claims and removes expired or tampered stor assert.equal(restoreSession(), false) assert.equal(values.has('overseas-pda-auth-session'), false) - const tampered = { ...saved, token: tokenPair({ overseas: { overseas_warehouse_id: 901 } }).overseasAccessToken } + const tampered = { ...saved, token: tokenPair({ domestic: { overseas_warehouse_id: 901 } }).accessToken } signOut(); values.set('overseas-pda-auth-session', tampered) assert.equal(restoreSession(), false) assert.equal(session.currentUser, null) assert.equal(values.has('overseas-pda-auth-session'), false) - const expired = tokenPair({ domestic: { exp: 1 }, overseas: { exp: 1 } }) - values.set('overseas-pda-auth-session', { ...saved, domesticToken: expired.accessToken, token: expired.overseasAccessToken }) + const expired = tokenPair({ domestic: { exp: 1 } }) + values.set('overseas-pda-auth-session', { ...saved, token: expired.accessToken }) assert.equal(restoreSession(), false) assert.equal(values.has('overseas-pda-auth-session'), false) }) @@ -152,7 +170,6 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving or signOut() assert.equal(session.currentUser, null) assert.equal(session.token, '') - assert.equal(session.domesticToken, '') assert.equal(session.refreshToken, '') assert.deepEqual(session.authorization, { roles: [], authorities: [] }) assert.equal(values.has('overseas-pda-auth-session'), false) diff --git a/tests/warehouse-workflows.test.mjs b/tests/warehouse-workflows.test.mjs index 79f620a..e02984d 100644 --- a/tests/warehouse-workflows.test.mjs +++ b/tests/warehouse-workflows.test.mjs @@ -20,7 +20,7 @@ const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') function requestMock() { const calls = [] - session.apiBase = '/overseas-api'; session.token = 'warehouse-token' + session.apiBase = '/api'; session.token = 'warehouse-token' globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }) diff --git a/vite.config.js b/vite.config.js index d6a0949..51f2fe2 100644 --- a/vite.config.js +++ b/vite.config.js @@ -25,9 +25,7 @@ function loggedProxy(name, options) { export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') - const domesticTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000' - const overseasTarget = env.OVERSEAS_API_PROXY_TARGET || 'http://127.0.0.1:9300' - const preserveOverseasPrefix = env.OVERSEAS_API_PROXY_PRESERVE_PREFIX === 'true' + const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000' return { plugins: [uni()], define: { @@ -38,12 +36,7 @@ export default defineConfig(({ mode }) => { }, server: { proxy: { - '/api': loggedProxy('auth', { target: domesticTarget, changeOrigin: true }), - '/overseas-api': loggedProxy('overseas', { - target: overseasTarget, - changeOrigin: true, - rewrite: (path) => preserveOverseasPrefix ? path : path.replace(/^\/overseas-api/, '/api') - }) + '/api': loggedProxy('api', { target: apiTarget, changeOrigin: true }) } }, css: {