fix: harden mobile presign contracts and authorization.
This commit is contained in:
1 parent
0927a514d2
commit
06255d1e41
12 files changed
+324
-50
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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'] == []
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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']
|
||||
)
|
||||
})
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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), '-')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user