客服原来只能看到备用金现金,平台积分藏在 Tab 里且是空的。 现在四个钱包并列展示:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金。 后端: - 客服查询积分账户时自动补建三个空账户,保证四个钱包始终并列展示, 不会出现「点了没反应」的空白卡片。 前端: - 标题「我的备用金」→「我的钱包」,菜单同步改名。 - 顶部四张卡:三个积分平台各一张(显示积分余额 + 折合金额 + 换算率标签), 备用金现金一张。余额为 0 时置灰。 - 四个钱包卡片与备用金账户解耦:没开通备用金账户的客服也能看到积分钱包, 只是现金卡显示「尚未开通备用金账户」。 - Tab 调整为「积分流水 / 备用金流水 / 我的支出」,积分流水独立展示 (平台、类型、变动、余额、折合金额、备注、时间)。 - 备用金账户详情卡(账户编号、责任主管、状态、登记支出、缴回备用金) 仍然只在有账户时显示。 校验:后端 765 项 / 0 失败,前端 143 项全通过(含 lint、tsc -b、build)。 Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
586 lines
19 KiB
TypeScript
586 lines
19 KiB
TypeScript
import type { PoolClient } from 'pg'
|
||
|
||
import { query, withTransaction } from '../../db/client.js'
|
||
import type { JsonObject } from '../../types/json.js'
|
||
import { createHttpError } from '../../utils/http.js'
|
||
import { nowIso } from '../../utils/time.js'
|
||
import type { AdminSession } from '../admin/admin-auth-service.js'
|
||
|
||
/**
|
||
* 客服平台积分钱包服务(南航 / 乐玩 / ROCK)。
|
||
*
|
||
* 口径(2026-10-24 确认):
|
||
* * 三平台积分**互不通用**,每客服每平台一个账户;
|
||
* * 换算率按平台不同:南航 1 积分 = 1 分,乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元;
|
||
* 真源是 fulfillment_profiles.config_json.rechargeUnitPriceFen;
|
||
* * 余额只能由流水产生,不允许手工改;流水只追加,不修改不删除;
|
||
* * 消耗不足直接拒绝,不允许负余额(与备用金一致);
|
||
* * 备用金现金**不走这里**,它复用现有的 fund_accounts / fund_entries。
|
||
*/
|
||
|
||
export const PLATFORM_CREDIT_KEYS = ['nanhang', 'lewan', 'rock'] as const
|
||
export type PlatformCreditKey = (typeof PLATFORM_CREDIT_KEYS)[number]
|
||
|
||
const PLATFORM_LABELS: Record<PlatformCreditKey, string> = {
|
||
nanhang: '南航平台',
|
||
lewan: '乐玩后台',
|
||
rock: 'ROCK 后台',
|
||
}
|
||
|
||
type CreditAccountRow = {
|
||
id: number
|
||
platform_key: string
|
||
owner_admin_user_id: number
|
||
balance_quantity: string | number
|
||
unit_name: string
|
||
recharge_unit_price_fen: string | number
|
||
status: string
|
||
owner_name?: string | null
|
||
username?: string | null
|
||
created_at: string
|
||
updated_at: string
|
||
}
|
||
|
||
type CreditEntryRow = {
|
||
id: number
|
||
account_id: number
|
||
entry_type: string
|
||
quantity_delta: string | number
|
||
balance_after: string | number
|
||
amount_fen: string | number
|
||
platform_ref: string
|
||
related_task_id: number | null
|
||
note: string
|
||
created_at: string
|
||
platform_key?: string
|
||
owner_name?: string | null
|
||
}
|
||
|
||
/** 读取平台档案里配置的换算率(分/积分)。 */
|
||
async function loadUnitPrices(): Promise<Record<PlatformCreditKey, number>> {
|
||
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
|
||
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
|
||
)
|
||
const prices: Record<PlatformCreditKey, number> = { nanhang: 1, lewan: 1, rock: 100 }
|
||
for (const row of result.rows) {
|
||
const raw = Number(row.config_json?.rechargeUnitPriceFen)
|
||
const key = row.profile_key.replace(/^manual_/, '') as PlatformCreditKey
|
||
if (key in prices && Number.isInteger(raw) && raw > 0) {
|
||
prices[key] = raw
|
||
}
|
||
}
|
||
return prices
|
||
}
|
||
|
||
/** 客服只能看自己的积分钱包;管理员/财务可看全部。 */
|
||
function isPrivileged(session: AdminSession) {
|
||
return session.role === 'admin' || session.role === 'finance' || session.role === 'operator'
|
||
}
|
||
|
||
/**
|
||
* 列出积分钱包。客服返回自己的三个平台账户(没有就补建空账户)。
|
||
*/
|
||
export async function listPlatformCreditAccounts(session: AdminSession) {
|
||
const unitPrices = await loadUnitPrices()
|
||
const params: unknown[] = []
|
||
const clauses: string[] = []
|
||
|
||
if (!isPrivileged(session)) {
|
||
params.push(session.userId)
|
||
clauses.push(`pca.owner_admin_user_id = $${params.length}`)
|
||
}
|
||
|
||
// 客服第一次进来时补建三个空账户,保证四个钱包始终并列展示。
|
||
if (!isPrivileged(session)) {
|
||
await ensurePlatformCreditAccounts(session.userId)
|
||
}
|
||
|
||
const where = clauses.length > 0 ? `AND ${clauses.join(' AND ')}` : ''
|
||
const result = await query<CreditAccountRow>(
|
||
`
|
||
SELECT
|
||
pca.id, pca.platform_key, pca.owner_admin_user_id, pca.balance_quantity,
|
||
pca.unit_name, pca.recharge_unit_price_fen, pca.status,
|
||
pca.created_at, pca.updated_at,
|
||
COALESCE(NULLIF(au.display_name, ''), au.username) AS owner_name,
|
||
au.username
|
||
FROM platform_credit_accounts pca
|
||
LEFT JOIN admin_users au ON au.id = pca.owner_admin_user_id
|
||
WHERE 1 = 1 ${where}
|
||
ORDER BY pca.platform_key, pca.owner_admin_user_id
|
||
`,
|
||
params,
|
||
)
|
||
|
||
return {
|
||
items: result.rows.map((row) => mapAccount(row, unitPrices)),
|
||
platforms: PLATFORM_CREDIT_KEYS.map((key) => ({
|
||
key,
|
||
label: PLATFORM_LABELS[key],
|
||
creditUnitPriceFen: unitPrices[key],
|
||
})),
|
||
}
|
||
}
|
||
|
||
/** 给客服补建三个平台的空积分账户(没有才建)。 */
|
||
export async function ensurePlatformCreditAccounts(ownerAdminUserId: number): Promise<void> {
|
||
const unitPrices = await loadUnitPrices()
|
||
const now = nowIso()
|
||
for (const key of PLATFORM_CREDIT_KEYS) {
|
||
await query(
|
||
`
|
||
INSERT INTO platform_credit_accounts (
|
||
platform_key, owner_admin_user_id, balance_quantity, unit_name,
|
||
recharge_unit_price_fen, status, created_at, updated_at
|
||
) VALUES ($1, $2, 0, '积分', $3, 'active', $4, $4)
|
||
ON CONFLICT (owner_admin_user_id, platform_key) DO NOTHING
|
||
`,
|
||
[key, ownerAdminUserId, unitPrices[key], now],
|
||
)
|
||
}
|
||
}
|
||
|
||
/** 某个客服在某个平台的积分余额(0 = 没账户)。 */
|
||
export async function getPlatformCreditBalance(
|
||
ownerAdminUserId: number,
|
||
platformKey: PlatformCreditKey,
|
||
) {
|
||
const result = await query<CreditAccountRow>(
|
||
`SELECT * FROM platform_credit_accounts
|
||
WHERE owner_admin_user_id = $1 AND platform_key = $2
|
||
LIMIT 1`,
|
||
[ownerAdminUserId, platformKey],
|
||
)
|
||
const row = result.rows[0]
|
||
if (!row) return null
|
||
const unitPrices = await loadUnitPrices()
|
||
return mapAccount(row, unitPrices)
|
||
}
|
||
|
||
/**
|
||
* 充值:公司往客服的平台积分账户加额度。
|
||
*
|
||
* 只有管理员/财务能充值;客服角色只能查看。
|
||
*/
|
||
export async function grantPlatformCredit(payload: JsonObject, session: AdminSession) {
|
||
if (!isPrivileged(session)) {
|
||
throw createHttpError('只有管理员或财务可以充值积分', {
|
||
statusCode: 403,
|
||
errorCode: 'platform_credit_grant_forbidden',
|
||
})
|
||
}
|
||
|
||
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
|
||
const ownerAdminUserId = normalizeRequiredId(
|
||
payload.ownerAdminUserId ?? payload.owner_admin_user_id,
|
||
'请选择客服',
|
||
)
|
||
const quantity = normalizePositiveQuantity(payload.quantity)
|
||
const platformRef = optionalText(payload.platformRef ?? payload.platform_ref, 100)
|
||
const note = optionalText(payload.note, 300)
|
||
|
||
const unitPrices = await loadUnitPrices()
|
||
const unitPriceFen = unitPrices[platformKey]
|
||
const now = nowIso()
|
||
|
||
return withTransaction(async (client) => {
|
||
await ensureAccountRow(client, ownerAdminUserId, platformKey, unitPriceFen, now)
|
||
const account = await lockAccount(client, ownerAdminUserId, platformKey)
|
||
const nextBalance = Number(account.balance_quantity) + quantity
|
||
|
||
await client.query(
|
||
`UPDATE platform_credit_accounts
|
||
SET balance_quantity = $2, updated_at = $3
|
||
WHERE id = $1`,
|
||
[account.id, nextBalance, now],
|
||
)
|
||
const entryResult = await client.query<CreditEntryRow>(
|
||
`
|
||
INSERT INTO platform_credit_entries (
|
||
account_id, entry_type, quantity_delta, balance_after, amount_fen,
|
||
platform_ref, note, created_at
|
||
) VALUES ($1, 'grant', $2, $3, $4, $5, $6, $7)
|
||
RETURNING *
|
||
`,
|
||
[account.id, quantity, nextBalance, quantity * unitPriceFen, platformRef, note, now],
|
||
)
|
||
return {
|
||
accountId: Number(account.id),
|
||
platformKey,
|
||
quantity,
|
||
balanceAfter: nextBalance,
|
||
amountFen: quantity * unitPriceFen,
|
||
entryId: Number(entryResult.rows[0]?.id || 0),
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 人工调整余额(正负都允许,但不能把余额压成负数)。 */
|
||
export async function adjustPlatformCredit(payload: JsonObject, session: AdminSession) {
|
||
if (!isPrivileged(session)) {
|
||
throw createHttpError('只有管理员或财务可以调整积分', {
|
||
statusCode: 403,
|
||
errorCode: 'platform_credit_adjust_forbidden',
|
||
})
|
||
}
|
||
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
|
||
const ownerAdminUserId = normalizeRequiredId(
|
||
payload.ownerAdminUserId ?? payload.owner_admin_user_id,
|
||
'请选择客服',
|
||
)
|
||
const quantity = normalizeNonZeroQuantity(payload.quantity)
|
||
const note = optionalText(payload.note, 300)
|
||
if (!note) {
|
||
throw createHttpError('调整积分必须填写原因', {
|
||
statusCode: 400,
|
||
errorCode: 'platform_credit_adjust_note_required',
|
||
})
|
||
}
|
||
const unitPrices = await loadUnitPrices()
|
||
const unitPriceFen = unitPrices[platformKey]
|
||
const now = nowIso()
|
||
|
||
return withTransaction(async (client) => {
|
||
await ensureAccountRow(client, ownerAdminUserId, platformKey, unitPriceFen, now)
|
||
const account = await lockAccount(client, ownerAdminUserId, platformKey)
|
||
const nextBalance = Number(account.balance_quantity) + quantity
|
||
if (nextBalance < 0) {
|
||
throw createHttpError('调整后积分余额不能为负', {
|
||
statusCode: 409,
|
||
errorCode: 'platform_credit_balance_negative',
|
||
})
|
||
}
|
||
await client.query(
|
||
`UPDATE platform_credit_accounts
|
||
SET balance_quantity = $2, updated_at = $3
|
||
WHERE id = $1`,
|
||
[account.id, nextBalance, now],
|
||
)
|
||
const entryResult = await client.query<CreditEntryRow>(
|
||
`
|
||
INSERT INTO platform_credit_entries (
|
||
account_id, entry_type, quantity_delta, balance_after, amount_fen,
|
||
note, created_at
|
||
) VALUES ($1, 'adjust', $2, $3, $4, $5, $6)
|
||
RETURNING *
|
||
`,
|
||
[account.id, quantity, nextBalance, quantity * unitPriceFen, note, now],
|
||
)
|
||
return {
|
||
accountId: Number(account.id),
|
||
platformKey,
|
||
quantity,
|
||
balanceAfter: nextBalance,
|
||
amountFen: quantity * unitPriceFen,
|
||
entryId: Number(entryResult.rows[0]?.id || 0),
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 消耗积分:客服履约时扣自己的某个平台钱包。
|
||
*
|
||
* 余额不足直接拒绝(409),不允许负余额。
|
||
*/
|
||
export async function consumePlatformCredit(input: {
|
||
ownerAdminUserId: number
|
||
platformKey: PlatformCreditKey
|
||
quantity: number
|
||
relatedTaskId?: number | null
|
||
platformRef?: string
|
||
note?: string
|
||
}): Promise<{ entryId: number; balanceAfter: number; amountFen: number }> {
|
||
const unitPrices = await loadUnitPrices()
|
||
const unitPriceFen = unitPrices[input.platformKey]
|
||
const now = nowIso()
|
||
|
||
return withTransaction(async (client) => {
|
||
await ensureAccountRow(client, input.ownerAdminUserId, input.platformKey, unitPriceFen, now)
|
||
const account = await lockAccount(client, input.ownerAdminUserId, input.platformKey)
|
||
const balance = Number(account.balance_quantity)
|
||
if (balance < input.quantity) {
|
||
throw createHttpError(
|
||
`${PLATFORM_LABELS[input.platformKey]}积分不足(当前 ${balance},需要 ${input.quantity}),请联系主管充值`,
|
||
{
|
||
statusCode: 409,
|
||
errorCode: 'platform_credit_balance_insufficient',
|
||
},
|
||
)
|
||
}
|
||
const nextBalance = balance - input.quantity
|
||
await client.query(
|
||
`UPDATE platform_credit_accounts
|
||
SET balance_quantity = $2, updated_at = $3
|
||
WHERE id = $1`,
|
||
[account.id, nextBalance, now],
|
||
)
|
||
const entryResult = await client.query<CreditEntryRow>(
|
||
`
|
||
INSERT INTO platform_credit_entries (
|
||
account_id, entry_type, quantity_delta, balance_after, amount_fen,
|
||
platform_ref, related_task_id, note, created_at
|
||
) VALUES ($1, 'consume', $2, $3, $4, $5, $6, $7, $8)
|
||
RETURNING *
|
||
`,
|
||
[
|
||
account.id,
|
||
-input.quantity,
|
||
nextBalance,
|
||
input.quantity * unitPriceFen,
|
||
input.platformRef || '',
|
||
input.relatedTaskId ?? null,
|
||
input.note || '',
|
||
now,
|
||
],
|
||
)
|
||
return {
|
||
entryId: Number(entryResult.rows[0]?.id || 0),
|
||
balanceAfter: nextBalance,
|
||
amountFen: input.quantity * unitPriceFen,
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 积分流水列表。 */
|
||
export async function listPlatformCreditEntries(
|
||
input: Record<string, unknown>,
|
||
session: AdminSession,
|
||
) {
|
||
const unitPrices = await loadUnitPrices()
|
||
const page = normalizePositiveInteger(input.page, 1, '页码')
|
||
const pageSize = Math.min(
|
||
200,
|
||
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
|
||
)
|
||
const params: unknown[] = []
|
||
const clauses: string[] = []
|
||
|
||
if (!isPrivileged(session)) {
|
||
params.push(session.userId)
|
||
clauses.push(`pca.owner_admin_user_id = $${params.length}`)
|
||
}
|
||
const platformKey = String(input.platformKey || '').trim()
|
||
if (platformKey) {
|
||
params.push(platformKey)
|
||
clauses.push(`pca.platform_key = $${params.length}`)
|
||
}
|
||
const entryType = String(input.entryType || '').trim()
|
||
if (entryType) {
|
||
params.push(entryType)
|
||
clauses.push(`pce.entry_type = $${params.length}`)
|
||
}
|
||
const keyword = String(input.keyword || '').trim()
|
||
if (keyword) {
|
||
params.push(`%${escapeLike(keyword)}%`)
|
||
const placeholder = `$${params.length}`
|
||
clauses.push(`(pce.note ILIKE ${placeholder} OR pce.platform_ref ILIKE ${placeholder})`)
|
||
}
|
||
|
||
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
|
||
const countResult = await query<{ total: number }>(
|
||
`
|
||
SELECT COUNT(*)::int AS total
|
||
FROM platform_credit_entries pce
|
||
JOIN platform_credit_accounts pca ON pca.id = pce.account_id
|
||
${where}
|
||
`,
|
||
params,
|
||
)
|
||
const listParams = [...params, pageSize, (page - 1) * pageSize]
|
||
const result = await query<CreditEntryRow>(
|
||
`
|
||
SELECT
|
||
pce.*, pca.platform_key,
|
||
COALESCE(NULLIF(au.display_name, ''), au.username) AS owner_name
|
||
FROM platform_credit_entries pce
|
||
JOIN platform_credit_accounts pca ON pca.id = pce.account_id
|
||
LEFT JOIN admin_users au ON au.id = pca.owner_admin_user_id
|
||
${where}
|
||
ORDER BY pce.created_at DESC, pce.id DESC
|
||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
|
||
`,
|
||
listParams,
|
||
)
|
||
|
||
return {
|
||
items: result.rows.map((row) => mapEntry(row, unitPrices)),
|
||
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
|
||
}
|
||
}
|
||
|
||
/** 可选客服列表(充值/调整时选人)。 */
|
||
export async function listPlatformCreditOwners() {
|
||
const result = await query<{
|
||
id: number
|
||
username: string
|
||
display_name: string
|
||
}>(
|
||
`
|
||
SELECT
|
||
au.id, au.username, COALESCE(NULLIF(au.display_name, ''), au.username) AS display_name
|
||
FROM admin_users au
|
||
WHERE au.status = 'active'
|
||
ORDER BY au.id
|
||
`,
|
||
)
|
||
return {
|
||
items: result.rows.map((row) => ({
|
||
id: Number(row.id),
|
||
username: row.username,
|
||
displayName: row.display_name,
|
||
})),
|
||
}
|
||
}
|
||
|
||
async function ensureAccountRow(
|
||
client: PoolClient,
|
||
ownerAdminUserId: number,
|
||
platformKey: PlatformCreditKey,
|
||
unitPriceFen: number,
|
||
now: string,
|
||
) {
|
||
await client.query(
|
||
`
|
||
INSERT INTO platform_credit_accounts (
|
||
platform_key, owner_admin_user_id, balance_quantity, unit_name,
|
||
recharge_unit_price_fen, status, created_at, updated_at
|
||
) VALUES ($1, $2, 0, '积分', $3, 'active', $4, $4)
|
||
ON CONFLICT (owner_admin_user_id, platform_key) DO NOTHING
|
||
`,
|
||
[platformKey, ownerAdminUserId, unitPriceFen, now],
|
||
)
|
||
}
|
||
|
||
async function lockAccount(
|
||
client: PoolClient,
|
||
ownerAdminUserId: number,
|
||
platformKey: PlatformCreditKey,
|
||
) {
|
||
const result = await client.query<CreditAccountRow>(
|
||
`
|
||
SELECT * FROM platform_credit_accounts
|
||
WHERE owner_admin_user_id = $1 AND platform_key = $2
|
||
FOR UPDATE
|
||
`,
|
||
[ownerAdminUserId, platformKey],
|
||
)
|
||
const row = result.rows[0]
|
||
if (!row) {
|
||
throw createHttpError('积分账户不存在', {
|
||
statusCode: 404,
|
||
errorCode: 'platform_credit_account_not_found',
|
||
})
|
||
}
|
||
if (row.status !== 'active') {
|
||
throw createHttpError('积分账户已冻结或关闭', {
|
||
statusCode: 409,
|
||
errorCode: 'platform_credit_account_inactive',
|
||
})
|
||
}
|
||
return row
|
||
}
|
||
|
||
function mapAccount(row: CreditAccountRow, unitPrices: Record<PlatformCreditKey, number>) {
|
||
const platformKey = row.platform_key as PlatformCreditKey
|
||
const unitPriceFen = Number(row.recharge_unit_price_fen || unitPrices[platformKey] || 1)
|
||
return {
|
||
id: Number(row.id),
|
||
platformKey,
|
||
platformLabel: PLATFORM_LABELS[platformKey] || row.platform_key,
|
||
ownerAdminUserId: Number(row.owner_admin_user_id),
|
||
ownerName: row.owner_name || row.username || '',
|
||
balanceQuantity: Number(row.balance_quantity || 0),
|
||
balanceAmountFen: Number(row.balance_quantity || 0) * unitPriceFen,
|
||
unitName: row.unit_name || '积分',
|
||
creditUnitPriceFen: unitPriceFen,
|
||
status: row.status,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
}
|
||
}
|
||
|
||
function mapEntry(row: CreditEntryRow, unitPrices: Record<PlatformCreditKey, number>) {
|
||
const platformKey = (row.platform_key || '') as PlatformCreditKey
|
||
return {
|
||
id: Number(row.id),
|
||
accountId: Number(row.account_id),
|
||
platformKey,
|
||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||
ownerName: row.owner_name || '',
|
||
entryType: row.entry_type,
|
||
quantityDelta: Number(row.quantity_delta || 0),
|
||
balanceAfter: Number(row.balance_after || 0),
|
||
amountFen: Number(row.amount_fen || 0),
|
||
platformRef: row.platform_ref || '',
|
||
relatedTaskId: row.related_task_id ? Number(row.related_task_id) : null,
|
||
note: row.note || '',
|
||
createdAt: row.created_at,
|
||
}
|
||
}
|
||
|
||
function normalizePlatformKey(value: unknown): PlatformCreditKey {
|
||
const text = String(value || '').trim()
|
||
if (!PLATFORM_CREDIT_KEYS.includes(text as PlatformCreditKey)) {
|
||
throw createHttpError('积分平台无效', {
|
||
statusCode: 400,
|
||
errorCode: 'platform_credit_platform_invalid',
|
||
})
|
||
}
|
||
return text as PlatformCreditKey
|
||
}
|
||
|
||
function normalizeRequiredId(value: unknown, message: string) {
|
||
const parsed = Number(value)
|
||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||
throw createHttpError(message, {
|
||
statusCode: 400,
|
||
errorCode: 'platform_credit_owner_invalid',
|
||
})
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
function normalizePositiveQuantity(value: unknown) {
|
||
const parsed = Number(value)
|
||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||
throw createHttpError('积分数必须是正整数', {
|
||
statusCode: 400,
|
||
errorCode: 'platform_credit_quantity_invalid',
|
||
})
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
function normalizeNonZeroQuantity(value: unknown) {
|
||
const parsed = Number(value)
|
||
if (!Number.isInteger(parsed) || parsed === 0) {
|
||
throw createHttpError('调整积分数必须是非零整数', {
|
||
statusCode: 400,
|
||
errorCode: 'platform_credit_quantity_invalid',
|
||
})
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
function normalizePositiveInteger(value: unknown, fallback: number, label: string) {
|
||
const parsed = Number(value)
|
||
if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
|
||
if (!value) return fallback
|
||
throw createHttpError(`${label}必须是正整数`, {
|
||
statusCode: 400,
|
||
errorCode: 'platform_credit_pagination_invalid',
|
||
})
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
function optionalText(value: unknown, maxLength: number) {
|
||
return String(value || '')
|
||
.trim()
|
||
.slice(0, maxLength)
|
||
}
|
||
|
||
function escapeLike(value: string) {
|
||
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
|
||
}
|