Просмотр исходного кода

fix: harden mobile presign contracts and authorization.

zlwu 2 недель назад
Родитель
Сommit
06255d1e41

+ 3 - 0
backend/app/services/market_support.py

@@ -708,6 +708,9 @@ def decorate_allowed_actions(
         return data
     if module == 'presign' and detail:
         parent_writable = status.lower() in {'', 'draft', 'active'}
+        data['transferAllowedActions'] = (
+            ['create-transfer'] if parent_writable else []
+        )
         transfers = []
         for transfer in data.get('transfers') or []:
             transfer_data = dict(transfer)

+ 28 - 0
backend/tests/test_market_presign_mobile_actions.py

@@ -0,0 +1,28 @@
+import pytest
+
+from app.services.market_support import decorate_allowed_actions
+
+
+@pytest.mark.parametrize('status', ['', 'draft', 'active'])
+def test_presign_detail_authorizes_transfer_creation_for_writable_parent(status):
+    result = decorate_allowed_actions(
+        'presign',
+        {'orderStatus': status, 'transfers': []},
+        scope_kind='specialist',
+        detail=True,
+    )
+
+    assert result['allowedActions'] == ['edit', 'convert', 'delete']
+    assert result['transferAllowedActions'] == ['create-transfer']
+
+
+@pytest.mark.parametrize('status', ['void', 'converted', 'unexpected'])
+def test_presign_detail_denies_transfer_creation_for_locked_parent(status):
+    result = decorate_allowed_actions(
+        'presign',
+        {'orderStatus': status, 'transfers': []},
+        scope_kind='manager',
+        detail=True,
+    )
+
+    assert result['transferAllowedActions'] == []

+ 54 - 12
mobile-oa-h5/src/api/market.js

@@ -19,11 +19,30 @@ function assertContext(context) {
   }
 }
 
+export function normalizeMarketEntityId(id, { rejectPathSeparators = false } = {}) {
+  if (id == null) throw new Error('业务实体 ID 无效')
+  const normalized = String(id).trim()
+  if (
+    !normalized ||
+    normalized.length > 64 ||
+    normalized === '.' ||
+    normalized === '..' ||
+    /[\u0000-\u001f\u007f-\u009f]/.test(normalized) ||
+    (rejectPathSeparators && /[\\/]/.test(normalized))
+  ) {
+    throw new Error('业务实体 ID 无效')
+  }
+  return normalized
+}
+
 export function buildMarketResourcePath(context, id) {
   assertContext(context)
   const base = `/mini/market/${context.marketRole}/${RESOURCE_BY_MODULE[context.module]}`
-  const normalizedId = String(id ?? '').trim()
-  return normalizedId ? `${base}/${encodeURIComponent(normalizedId)}` : base
+  if (arguments.length < 2) return base
+  const normalizedId = normalizeMarketEntityId(id, {
+    rejectPathSeparators: context.module === 'presign'
+  })
+  return `${base}/${encodeURIComponent(normalizedId)}`
 }
 
 export function buildMarketFormOptionsPath(context) {
@@ -36,11 +55,14 @@ export function buildMarketAttachmentPath(context) {
   return `/mini/market/${context.marketRole}/${context.module}/attachments`
 }
 
-export function buildMarketActionPath(context, action, id) {
+export function buildMarketActionPath(context, action, id, data = {}) {
   assertContext(context)
   const roleBase = `/mini/market/${context.marketRole}`
-  const encodedId = encodeURIComponent(String(id ?? '').trim())
-  if (context.module === 'presign' && encodedId) {
+  const encodeId = () => encodeURIComponent(normalizeMarketEntityId(id, {
+    rejectPathSeparators: context.module === 'presign'
+  }))
+  if (context.module === 'presign') {
+    const encodedId = encodeId()
     if (action === 'delete') return buildMarketResourcePath(context, id)
     if (action === 'convert') return `${buildMarketResourcePath(context, id)}/convert`
     if (action === 'create-transfer') {
@@ -56,15 +78,27 @@ export function buildMarketActionPath(context, action, id) {
   }
   if (context.module === 'payment') {
     if (action === 'register-transfer') return `${roleBase}/payment-transfers`
-    if (['edit-transfer', 'submit', 'withdraw'].includes(action) && encodedId) {
+    if (['edit-transfer', 'submit', 'withdraw'].includes(action)) {
+      const encodedId = encodeId()
       const suffix = action === 'edit-transfer' ? '' : `/${action}`
       return `${roleBase}/payment-transfers/${encodedId}${suffix}`
     }
   }
-  if (context.module === 'sample' && ['storage-cert', 'insurance'].includes(action) && encodedId) {
+  if (context.module === 'protocol' && ['audit', 'delete'].includes(action)) {
+    encodeId()
+    const source = String(data?.source || '').trim()
+    if (!['cell_storage', 'research_donation'].includes(source)) {
+      throw new Error('协议业务来源无效')
+    }
+    const suffix = action === 'audit' ? '/audit' : ''
+    return `${buildMarketResourcePath(context, id)}${suffix}?source=${encodeURIComponent(source)}`
+  }
+  if (context.module === 'sample' && ['storage-cert', 'insurance'].includes(action)) {
+    encodeId()
     return `${buildMarketResourcePath(context, id)}/${action}`
   }
-  if (context.module === 'freight' && ['link', 'unlink'].includes(action) && encodedId) {
+  if (context.module === 'freight' && ['link', 'unlink'].includes(action)) {
+    encodeId()
     return `${buildMarketResourcePath(context, id)}/${action}`
   }
   throw new Error('不支持的市场业务操作')
@@ -97,9 +131,14 @@ export async function fetchMarketFormOptions(context, params, { signal } = {}) {
 
 export async function saveMarketRecord(context, { id, data }, { signal } = {}) {
   const { request } = await import('../utils/http.js')
+  const normalizedId = id == null || id === ''
+    ? ''
+    : normalizeMarketEntityId(id, { rejectPathSeparators: context?.module === 'presign' })
   return request({
-    url: buildMarketResourcePath(context, id),
-    method: id ? 'PUT' : 'POST',
+    url: normalizedId
+      ? buildMarketResourcePath(context, normalizedId)
+      : buildMarketResourcePath(context),
+    method: normalizedId ? 'PUT' : 'POST',
     data,
     signal
   })
@@ -114,10 +153,13 @@ export function marketActionMethod(action) {
 
 export async function performMarketAction(context, action, { id, data } = {}, { signal } = {}) {
   const { request } = await import('../utils/http.js')
+  const requestData = context.module === 'protocol' && ['audit', 'delete'].includes(action)
+    ? Object.fromEntries(Object.entries(data || {}).filter(([key]) => key !== 'source'))
+    : data
   return request({
-    url: buildMarketActionPath(context, action, id),
+    url: buildMarketActionPath(context, action, id, data),
     method: marketActionMethod(action),
-    data,
+    data: action === 'delete' && context.module === 'protocol' ? undefined : requestData,
     signal
   })
 }

+ 19 - 5
mobile-oa-h5/src/market/action-policy.js

@@ -31,17 +31,17 @@ const PRESIGN_TOP_ACTIONS = new Set([
   'create',
   'edit',
   'convert',
-  'delete',
-  'create-transfer'
+  'delete'
 ])
 
 const PRESIGN_ORDER_ACTIONS = new Set([
   'edit',
   'convert',
-  'delete',
-  'create-transfer'
+  'delete'
 ])
 
+const PRESIGN_TRANSFER_CREATE_ACTIONS = new Set(['create-transfer'])
+
 const PRESIGN_TRANSFER_ACTION_MAP = Object.freeze({
   edit: 'edit-presign-transfer',
   delete: 'delete-transfer',
@@ -123,9 +123,23 @@ export function collectAllowedActions(module, role, detail) {
         return { ...metadata, code }
       })
       .filter(Boolean)
+    const createTransfer = (Array.isArray(detail?.transferAllowedActions)
+      ? detail.transferAllowedActions
+      : [])
+      .map((action) => {
+        const code = typeof action === 'string'
+          ? action
+          : String(action?.code || action?.action || action?.key || '').trim()
+        if (!PRESIGN_TRANSFER_CREATE_ACTIONS.has(code) || !frontendWhitelist.has(code)) return null
+        if (typeof action === 'string') return action
+        const { key: _key, action: _action, ...metadata } = action
+        return { ...metadata, code }
+      })
+      .filter(Boolean)
     return [
       ...nested,
-      ...top
+      ...top,
+      ...createTransfer
     ]
   }
   if (module !== 'payment') {

+ 52 - 4
mobile-oa-h5/src/market/api-contract.test.js

@@ -8,8 +8,11 @@ import {
   createPresignTransfer,
   deletePresignOrder,
   deletePresignTransfer,
+  fetchMarketDetail,
   marketActionMethod,
   buildMarketResourcePath,
+  normalizeMarketEntityId,
+  saveMarketRecord,
   submitPresignTransfer,
   updatePresignTransfer,
   withdrawPresignTransfer
@@ -53,16 +56,16 @@ test('maps every presign write action to the fixed backend route with encoded id
     '/mini/market/specialist/presign-orders/order%201'
   )
   assert.equal(
-    buildMarketActionPath(presign, 'create-transfer', 'order/1'),
-    '/mini/market/specialist/presign-orders/order%2F1/transfers'
+    buildMarketActionPath(presign, 'create-transfer', 'order 1'),
+    '/mini/market/specialist/presign-orders/order%201/transfers'
   )
   assert.equal(
     buildMarketActionPath(presign, 'edit-presign-transfer', 'transfer 1'),
     '/mini/market/specialist/presign-transfers/transfer%201'
   )
   assert.equal(
-    buildMarketActionPath(presign, 'delete-transfer', 'transfer/1'),
-    '/mini/market/specialist/presign-transfers/transfer%2F1'
+    buildMarketActionPath(presign, 'delete-transfer', 'transfer 1'),
+    '/mini/market/specialist/presign-transfers/transfer%201'
   )
   assert.equal(
     buildMarketActionPath(presign, 'submit-transfer', 'transfer 1'),
@@ -74,6 +77,51 @@ test('maps every presign write action to the fixed backend route with encoded id
   )
 })
 
+test('normalizes safe entity ids and rejects dangerous presign path segments', () => {
+  const presign = { marketRole: 'specialist', module: 'presign' }
+  const uuid = '123e4567-e89b-12d3-a456-426614174000'
+
+  assert.equal(normalizeMarketEntityId(`  ${uuid}  `), uuid)
+  assert.equal(buildMarketResourcePath(presign, ' o 1 '), '/mini/market/specialist/presign-orders/o%201')
+  assert.equal(buildMarketActionPath(presign, 'submit-transfer', 't 1'), '/mini/market/specialist/presign-transfers/t%201/submit')
+  assert.equal(
+    buildMarketActionPath({ marketRole: 'specialist', module: 'protocol' }, 'delete', 'p/1', { source: 'cell_storage' }),
+    '/mini/market/specialist/protocols/p%2F1?source=cell_storage'
+  )
+
+  for (const id of [null, undefined, '', '   ', '.', '..', 'a/control\u0000id', 'a/control\u009fid', 'x'.repeat(65)]) {
+    assert.throws(() => normalizeMarketEntityId(id), /ID/)
+  }
+  assert.throws(() => buildMarketResourcePath(presign, undefined), /ID/)
+  for (const id of ['order/1', 'order\\1']) {
+    assert.throws(() => buildMarketResourcePath(presign, id), /ID/)
+    assert.throws(() => buildMarketActionPath(presign, 'delete', id), /ID/)
+  }
+})
+
+test('invalid detail, save, and presign action ids fail before an HTTP request', async () => {
+  const presign = { marketRole: 'specialist', module: 'presign' }
+  const originalFetch = globalThis.fetch
+  let calls = 0
+  globalThis.fetch = async () => {
+    calls += 1
+    throw new Error('fetch should not run')
+  }
+  try {
+    await assert.rejects(fetchMarketDetail(presign, '   '), /ID/)
+    await assert.rejects(saveMarketRecord(presign, { id: '   ', data: {} }), /ID/)
+    await assert.rejects(deletePresignOrder(presign, '..'), /ID/)
+    await assert.rejects(createPresignTransfer(presign, 'order/1', {}), /ID/)
+    await assert.rejects(updatePresignTransfer(presign, 'transfer\\1', {}), /ID/)
+    await assert.rejects(deletePresignTransfer(presign, ''), /ID/)
+    await assert.rejects(submitPresignTransfer(presign, null), /ID/)
+    await assert.rejects(withdrawPresignTransfer(presign, 'x'.repeat(65)), /ID/)
+    assert.equal(calls, 0)
+  } finally {
+    globalThis.fetch = originalFetch
+  }
+})
+
 test('uses the backend HTTP method for each presign action', () => {
   assert.equal(marketActionMethod('delete'), 'DELETE')
   assert.equal(marketActionMethod('create-transfer'), 'POST')

+ 44 - 0
mobile-oa-h5/src/market/market-signal.test.js

@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { fetchMarketList, saveMarketRecord } from '../api/market.js'
+
+const context = { marketRole: 'specialist', module: 'presign' }
+
+test('market wrappers pass the caller abort signal through to fetch', async () => {
+  const originalFetch = globalThis.fetch
+  const originalSessionStorage = globalThis.sessionStorage
+  const calls = []
+  globalThis.sessionStorage = { getItem: () => 'test-token' }
+  globalThis.fetch = async (url, init) => {
+    calls.push({ url, init })
+    return {
+      status: 200,
+      json: async () => ({ code: 200, data: { ok: true } })
+    }
+  }
+
+  try {
+    const listController = new AbortController()
+    const saveController = new AbortController()
+    await fetchMarketList(context, { page: 1 }, { signal: listController.signal })
+    await saveMarketRecord(context, { id: '', data: { motherName: '新增' } }, {
+      signal: saveController.signal
+    })
+    await saveMarketRecord(context, { id: ' o 1 ', data: { motherName: '张女士' } }, {
+      signal: saveController.signal
+    })
+
+    assert.equal(calls[0].init.signal, listController.signal)
+    assert.equal(calls[1].init.signal, saveController.signal)
+    assert.equal(calls[1].init.method, 'POST')
+    assert.match(calls[1].url, /presign-orders$/)
+    assert.equal(calls[2].init.signal, saveController.signal)
+    assert.equal(calls[2].init.method, 'PUT')
+    assert.match(calls[2].url, /presign-orders\/o%201$/)
+  } finally {
+    globalThis.fetch = originalFetch
+    if (originalSessionStorage === undefined) delete globalThis.sessionStorage
+    else globalThis.sessionStorage = originalSessionStorage
+  }
+})

+ 28 - 5
mobile-oa-h5/src/market/module-actions.test.js

@@ -128,7 +128,7 @@ test('presign actions never exceed the backend authorization set', () => {
       'finance-confirm',
       'admin-only'
     ]),
-    ['delete', 'create-transfer']
+    ['delete']
   )
   assert.equal(
     intersectAllowedActions('presign', 'specialist', ['edit']).includes('delete'),
@@ -149,7 +149,7 @@ test('presign order actions reject transfer-only backend action codes', () => {
       'submit-transfer',
       'withdraw-transfer'
     ]),
-    ['create', 'edit', 'convert', 'delete', 'create-transfer']
+    ['create', 'edit', 'convert', 'delete']
   )
 })
 
@@ -183,14 +183,17 @@ test('presign transfer actions accept only raw transfer authorization codes with
 })
 
 test('collects only backend-authorized presign order and nested transfer actions', () => {
-  const actions = collectAllowedActions('presign', 'manager', {
+  const detail = {
     allowedActions: ['create', 'edit', 'convert', 'delete', 'withdraw-transfer', 'admin-only'],
+    transferAllowedActions: ['create-transfer', 'admin-only'],
     transfers: [
       { id: 'draft', allowedActions: ['edit', 'delete', 'submit', 'finance-confirm'] },
       { id: 'pending', allowedActions: ['withdraw'] },
       { id: 'confirmed', allowedActions: [] }
     ]
-  })
+  }
+  const before = structuredClone(detail)
+  const actions = collectAllowedActions('presign', 'manager', detail)
 
   assert.deepEqual(
     actions.map((item) => [typeof item === 'string' ? item : item.code, item.transferId]),
@@ -201,9 +204,29 @@ test('collects only backend-authorized presign order and nested transfer actions
       ['withdraw-transfer', 'pending'],
       ['edit', undefined],
       ['convert', undefined],
-      ['delete', undefined]
+      ['delete', undefined],
+      ['create-transfer', undefined]
     ]
   )
   assert.equal(actions.some((item) => item.code === 'finance-confirm'), false)
   assert.equal(actions.some((item) => item.code === 'admin-only'), false)
+  assert.deepEqual(detail, before)
+})
+
+test('create transfer requires the dedicated backend authorization field', () => {
+  assert.deepEqual(
+    collectAllowedActions('presign', 'specialist', {
+      allowedActions: ['edit', 'create-transfer'],
+      transfers: []
+    }),
+    ['edit']
+  )
+  assert.deepEqual(
+    collectAllowedActions('presign', 'specialist', {
+      allowedActions: [],
+      transferAllowedActions: ['create-transfer'],
+      transfers: []
+    }),
+    ['create-transfer']
+  )
 })

+ 8 - 5
mobile-oa-h5/src/market/presign/presign-form-state.js

@@ -92,14 +92,17 @@ export function isPresignTransferLocked(transfer, orderOrStatus) {
 }
 
 function attachmentUrls(transfer) {
+  if (Array.isArray(transfer?.attachments)) {
+    return transfer.attachments
+      .filter((file) => !file?._uploading && !file?.failed && file?.status !== 'failed')
+      .map((file) => String(file?.fileUrl || file?.url || '').trim())
+      .filter(Boolean)
+      .join(',')
+  }
   if (typeof transfer?.transferAttachments === 'string') {
     return transfer.transferAttachments
   }
-  return (Array.isArray(transfer?.attachments) ? transfer.attachments : [])
-    .filter((file) => !file?._uploading && !file?.failed && file?.status !== 'failed')
-    .map((file) => String(file?.fileUrl || file?.url || '').trim())
-    .filter(Boolean)
-    .join(',')
+  return ''
 }
 
 export function buildPresignTransferPayload(transfer = {}) {

+ 28 - 0
mobile-oa-h5/src/market/presign/presign-form-state.test.js

@@ -171,3 +171,31 @@ test('builds a transfer payload without ids, status, timestamps, or finance fiel
     remark: ''
   })
 })
+
+test('uses the current attachment array instead of a stale backend URL string', () => {
+  assert.equal(buildPresignTransferPayload({
+    transferAttachments: 'old.pdf,removed.pdf',
+    attachments: [
+      { fileUrl: 'old.pdf' },
+      { url: 'added.pdf' },
+      { fileUrl: 'uploading.pdf', _uploading: true },
+      { fileUrl: 'failed.pdf', status: 'failed' }
+    ]
+  }).transferAttachments, 'old.pdf,added.pdf')
+
+  assert.equal(buildPresignTransferPayload({
+    transferAttachments: 'old.pdf',
+    attachments: [{ fileUrl: 'added.pdf' }]
+  }).transferAttachments, 'added.pdf')
+})
+
+test('an explicit empty attachment array deletes every transfer attachment', () => {
+  assert.equal(buildPresignTransferPayload({
+    transferAttachments: 'old.pdf',
+    attachments: []
+  }).transferAttachments, '')
+
+  assert.equal(buildPresignTransferPayload({
+    transferAttachments: 'fallback.pdf'
+  }).transferAttachments, 'fallback.pdf')
+})

+ 49 - 17
mobile-oa-h5/src/market/presign/presign-presenter.js

@@ -42,14 +42,42 @@ export function formatPresignDateTime(value) {
   return `${date} ${match[2]}:${match[3]}${seconds}`
 }
 
+function decimalParts(value) {
+  if (value == null) return null
+  if (typeof value === 'number' && !Number.isFinite(value)) return null
+  const text = String(value).trim()
+  if (!text) return null
+  const match = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/.exec(text)
+  if (!match) return null
+  const exponent = Number(match[5] || 0)
+  if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > 100) return null
+  const integerDigits = match[2] || ''
+  const fractionDigits = match[3] ?? match[4] ?? ''
+  const digits = `${integerDigits}${fractionDigits}` || '0'
+  const decimalPosition = integerDigits.length + exponent
+  const whole = decimalPosition <= 0
+    ? '0'
+    : `${digits.slice(0, decimalPosition)}${'0'.repeat(Math.max(0, decimalPosition - digits.length))}`
+  const fraction = decimalPosition <= 0
+    ? `${'0'.repeat(-decimalPosition)}${digits}`
+    : digits.slice(decimalPosition)
+  return {
+    negative: match[1] === '-',
+    whole: whole.replace(/^0+(?=\d)/, '') || '0',
+    fraction
+  }
+}
+
 export function formatPresignMoney(value) {
-  if (value == null || String(value).trim() === '') return '-'
-  const amount = Number(value)
-  if (!Number.isFinite(amount)) return '-'
-  return `¥${new Intl.NumberFormat('en-US', {
-    minimumFractionDigits: 2,
-    maximumFractionDigits: 2
-  }).format(amount)}`
+  const parts = decimalParts(value)
+  if (!parts) return '-'
+  const fraction = parts.fraction.padEnd(3, '0')
+  let cents = BigInt(parts.whole) * 100n + BigInt(fraction.slice(0, 2))
+  if (fraction[2] >= '5') cents += 1n
+  const integer = (cents / 100n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
+  const decimals = (cents % 100n).toString().padStart(2, '0')
+  const sign = parts.negative && cents !== 0n ? '-' : ''
+  return `¥${sign}${integer}.${decimals}`
 }
 
 export function presignStatusLabel(value) {
@@ -62,15 +90,19 @@ export function presignStatusTone(value) {
 }
 
 export function presignCardIndex(page, pageSize, position) {
-  const normalizedPage = Number(page)
-  const normalizedPageSize = Number(pageSize)
-  const normalizedPosition = Number(position)
-  if (
-    !Number.isInteger(normalizedPage) || normalizedPage < 1 ||
-    !Number.isInteger(normalizedPageSize) || normalizedPageSize < 1 ||
-    !Number.isInteger(normalizedPosition) || normalizedPosition < 1
-  ) {
-    return '-'
+  const positiveInteger = (value) => {
+    if (typeof value === 'bigint') return value > 0n ? value : null
+    if (typeof value === 'number') {
+      return Number.isSafeInteger(value) && value > 0 ? BigInt(value) : null
+    }
+    const text = String(value ?? '').trim()
+    if (!/^\+?\d{1,256}$/.test(text)) return null
+    const parsed = BigInt(text)
+    return parsed > 0n ? parsed : null
   }
-  return String((normalizedPage - 1) * normalizedPageSize + normalizedPosition)
+  const normalizedPage = positiveInteger(page)
+  const normalizedPageSize = positiveInteger(pageSize)
+  const normalizedPosition = positiveInteger(position)
+  if (normalizedPage == null || normalizedPageSize == null || normalizedPosition == null) return '-'
+  return ((normalizedPage - 1n) * normalizedPageSize + normalizedPosition).toString()
 }

+ 8 - 0
mobile-oa-h5/src/market/presign/presign-presenter.test.js

@@ -27,6 +27,9 @@ test('returns a safe placeholder for null, empty, or invalid dates', () => {
 test('formats finite amounts with a currency prefix and two decimals', () => {
   assert.equal(formatPresignMoney(28800), '¥28,800.00')
   assert.equal(formatPresignMoney('0'), '¥0.00')
+  assert.equal(formatPresignMoney('-12.345'), '¥-12.35')
+  assert.equal(formatPresignMoney('9999999999999999.99'), '¥9,999,999,999,999,999.99')
+  assert.equal(formatPresignMoney('1.005'), '¥1.01')
   for (const value of [null, undefined, '', 'invalid', Number.NaN, Number.POSITIVE_INFINITY]) {
     assert.equal(formatPresignMoney(value), '-')
   }
@@ -46,5 +49,10 @@ test('uses the Web presign status labels and mobile semantic tones', () => {
 test('computes a stable one-based sequence across pages', () => {
   assert.equal(presignCardIndex(1, 10, 1), '1')
   assert.equal(presignCardIndex(2, 10, 3), '13')
+  assert.equal(
+    presignCardIndex('900719925474099300000', '10000000000000000000', '7'),
+    '9007199254740992999990000000000000000007'
+  )
   assert.equal(presignCardIndex('bad', 10, 1), '-')
+  assert.equal(presignCardIndex(Number.MAX_SAFE_INTEGER + 1, 10, 1), '-')
 })

+ 3 - 2
mobile-oa-h5/src/utils/http.js

@@ -1,6 +1,6 @@
 import { getToken } from './auth.js'
 
-const API_BASE = (import.meta.env.VITE_API_BASE || '/api').replace(/\/$/, '')
+const API_BASE = (import.meta.env?.VITE_API_BASE || '/api').replace(/\/$/, '')
 
 function unwrap(body) {
   if (body == null || typeof body !== 'object') {
@@ -10,7 +10,7 @@ function unwrap(body) {
   throw new Error((body.message && String(body.message).trim()) || '请求失败')
 }
 
-export async function request({ url, method = 'GET', data, headers = {} }) {
+export async function request({ url, method = 'GET', data, headers = {}, signal }) {
   const path = url.startsWith('/') ? url : `/${url}`
   const full = `${API_BASE}${path}`
   const token = getToken()
@@ -19,6 +19,7 @@ export async function request({ url, method = 'GET', data, headers = {} }) {
   }
   const init = {
     method: method.toUpperCase(),
+    signal,
     headers: {
       Authorization: `Bearer ${token}`,
       ...headers