feat(finance): 人工履约 SKU 配置清单 + 四钱包(南航/乐玩/ROCK积分 + 备用金现金)

客服人工履约有四个钱包,成本与渠道在后台配置好,客服确认履约时零输入。

口径(2026-10-24 确认):
- 四个钱包:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金。三平台积分互不通用,
  每客服每平台一个积分账户;现金复用现有 fund_accounts / fund_entries。
- 积分换算率按平台不同:南航 1 积分 = 1 分,乐玩 1 积分 = 1 分,
  ROCK 1 积分 = 1 元(100 分)。真源是履约档案 config_json.rechargeUnitPriceFen。
- 积分是货币,幸运币是商品;消耗积分与成本金额按换算率一一对应。
- 配置主键是**内部 SKU**(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
  不是商品标题(标题是引流)。同名 SKU 算同一商品,跨渠道共用一份配置。
- 配置链路没有匹配引擎、没有打分:扫描 → 配置 → 重新匹配。
  10 月口径下人工单 17,469 单 / 357 个内部 SKU,Top 50 覆盖 88% 单量。
  更早的老单没配置意义,不纳入统计口径。
- 消耗积分填 0 = 成本不固定,客服在确认履约时手填实际成本;
  渠道始终提前配好,客服不选。

迁移 110:
- manual_sku_registry:扫描到的内部 SKU 登记表(统计 + 配置状态)。
- manual_fulfillment_configs:内部 SKU → 渠道 + 消耗积分 + 成本模式。
- platform_credit_accounts / platform_credit_entries:三平台积分钱包与流水。
- order_items.fulfillment_config_id:订单项挂配置,重新匹配时回填。
- 4 条 manual_* 履约档案(manual_dispatch 执行器 + config_json 区分平台)。
  不复用 kuaishou_ct_assisted / kuaishou_feifei——那是已下线的历史自动发货通道。

性能:spec_json->>'skuNick' 无索引时扫描 2.4s / 回填 5.4s;
新增 idx_order_items_manual_internal_sku 表达式索引后回填降到 1.7s。

后端:manual-fulfillment-config-service.ts(扫描/清单/配置CRUD/重新匹配),
成本中心挂 7 个接口。
前端:成本中心新增「人工 SKU 配置」Tab(扫描、清单、配置弹窗、重新匹配)。

校验:后端 765 项 / 0 失败(含 check:sql 迁移护栏),前端 143 项全通过;
E2E 实测扫描 357 个 SKU、配置四平台换算正确、重新匹配回填 7,604 个订单项;
本地库迁移 110 执行后数据量与改造前一致。

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-10-24 13:12:46 +08:00
co-authored by Claude Opus 4.5
parent fb679d76f6
commit 4bcb4e6b57
7 changed files with 1693 additions and 0 deletions
@@ -0,0 +1,678 @@
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'
/**
* 人工履约 SKU 配置服务。
*
* 口径(2026-10-24 确认):
* * 配置主键是**内部 SKU**(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
* 不是商品标题;同名 SKU 算同一个商品,跨渠道共用一份配置;
* * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金)+ 消耗积分 + 成本模式;
* * 换算率固定:1 积分 = 1 分钱;
* * 消耗积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
* * 「重新匹配」= 把配置回填到历史人工订单项,幂等可反复执行。
*
* 没有匹配引擎、没有打分:这就是一张带统计的 SKU 配置清单。
*/
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash'] as const
export type ManualPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
/**
* 积分换算率(分/积分),**按平台不同**:
* 南航 1 积分 = 1 分;乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元(100 分)。
* 备用金现金不涉及积分,换算率恒为 100(1 元 = 100 分)。
*
* 真源是 fulfillment_profiles.config_json.rechargeUnitPriceFen,
* 这里只是平台档案缺失时的兜底默认值。
*/
export const DEFAULT_CREDIT_UNIT_PRICE_FEN = 1
/** 平台 → 默认换算率(分/积分)。 */
const PLATFORM_UNIT_PRICE_FEN: Record<ManualPlatformKey, number> = {
nanhang: 1,
lewan: 1,
rock: 100,
fund_cash: 100,
}
const PLATFORM_LABELS: Record<ManualPlatformKey, string> = {
nanhang: '南航平台',
lewan: '乐玩后台',
rock: 'ROCK 后台',
fund_cash: '备用金现金',
}
/** 读取平台档案里配置的换算率;档案不存在时用默认表。 */
async function loadPlatformUnitPriceFen(): Promise<Record<ManualPlatformKey, 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<ManualPlatformKey, number> = { ...PLATFORM_UNIT_PRICE_FEN }
for (const row of result.rows) {
const raw = Number(row.config_json?.rechargeUnitPriceFen)
const key = row.profile_key.replace(/^manual_/, '') as ManualPlatformKey
if (key in prices && Number.isInteger(raw) && raw > 0) {
prices[key] = raw
}
}
return prices
}
type ScanOptions = {
fromDate: string
toDate: string
}
type ManualSkuRow = {
id: number
sku_key: string
display_name: string
shop_count: number
order_count: number
item_count: number
revenue_fen: number
first_seen_date: string | null
last_seen_date: string | null
scan_from_date: string | null
scan_to_date: string | null
config_id: number | null
config_platform_key: string | null
config_credit_quantity: number | null
config_cost_mode: string | null
config_canonical_product_id: number | null
config_enabled: boolean | null
}
/**
* 扫描「走到人工履约」的内部 SKU,登记到 manual_sku_registry。
*
* 幂等:重复扫描按 sku_key 覆盖统计,不产生重复行。
* 已有配置的 SKU 不受影响(配置存在 manual_fulfillment_configs 里)。
*/
export async function scanManualSkus(options: ScanOptions) {
const fromDate = normalizeDate(options.fromDate, '开始日期')
const toDate = normalizeDate(options.toDate, '结束日期')
if (fromDate > toDate) {
throw createHttpError('开始日期不能晚于结束日期', {
statusCode: 400,
errorCode: 'manual_sku_scan_range_invalid',
})
}
const unitPrices = await loadPlatformUnitPriceFen()
const now = nowIso()
const result = await query<{
sku_key: string
display_name: string
shop_count: number
order_count: number
item_count: number
revenue_fen: number
first_seen_date: string
last_seen_date: string
}>(
`
WITH scoped AS (
SELECT
oi.id AS order_item_id,
oi.order_id,
o.shop_id,
oi.sku_name,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
COALESCE(
NULLIF(oi.spec_json->>'skuNick', ''),
NULLIF(oi.spec_json->>'outSkuSn', '')
) AS sku_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_mode = 'manual'
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
),
keyed AS (
SELECT * FROM scoped WHERE sku_key IS NOT NULL AND sku_key <> ''
)
SELECT
sku_key,
MAX(sku_name) AS display_name,
COUNT(DISTINCT shop_id)::int AS shop_count,
COUNT(DISTINCT order_id)::int AS order_count,
COUNT(*)::int AS item_count,
SUM(revenue_fen)::bigint AS revenue_fen,
MIN(stat_date) AS first_seen_date,
MAX(stat_date) AS last_seen_date
FROM keyed
GROUP BY sku_key
`,
[fromDate, toDate],
)
const rows = result.rows
if (rows.length === 0) {
return { scanned: 0, registered: 0, fromDate, toDate }
}
await withTransaction(async (client) => {
for (const row of rows) {
await client.query(
`
INSERT INTO manual_sku_registry (
sku_key, display_name, shop_count, order_count, item_count, revenue_fen,
first_seen_date, last_seen_date, scan_from_date, scan_to_date,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7::date, $8::date, $9::date, $10::date, $11, $11
)
ON CONFLICT (sku_key) DO UPDATE
SET display_name = EXCLUDED.display_name,
shop_count = EXCLUDED.shop_count,
order_count = EXCLUDED.order_count,
item_count = EXCLUDED.item_count,
revenue_fen = EXCLUDED.revenue_fen,
first_seen_date = LEAST(
COALESCE(manual_sku_registry.first_seen_date, EXCLUDED.first_seen_date),
EXCLUDED.first_seen_date
),
last_seen_date = GREATEST(
COALESCE(manual_sku_registry.last_seen_date, EXCLUDED.last_seen_date),
EXCLUDED.last_seen_date
),
scan_from_date = EXCLUDED.scan_from_date,
scan_to_date = EXCLUDED.scan_to_date,
updated_at = EXCLUDED.updated_at
`,
[
row.sku_key,
row.display_name || '',
row.shop_count,
row.order_count,
row.item_count,
row.revenue_fen,
row.first_seen_date,
row.last_seen_date,
fromDate,
toDate,
now,
],
)
}
})
return { scanned: rows.length, registered: rows.length, fromDate, toDate }
}
/**
* 列出人工 SKU 清单(登记表 + 配置状态)。
*/
export async function listManualSkus(input: Record<string, unknown> = {}) {
const unitPrices = await loadPlatformUnitPriceFen()
const page = normalizePositiveInteger(input.page, 1, '页码')
const pageSize = Math.min(
200,
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
)
const keyword = String(input.keyword || input.q || '').trim()
const status = String(input.status || '').trim()
const platformKey = String(input.platformKey || input.platform_key || '').trim()
const provider = String(input.provider || '').trim()
const conditions: string[] = ['1 = 1']
const params: unknown[] = []
if (keyword) {
params.push(`%${escapeLike(keyword)}%`)
const placeholder = `$${params.length}`
conditions.push(`(r.sku_key ILIKE ${placeholder} OR r.display_name ILIKE ${placeholder})`)
}
if (status === 'configured') {
conditions.push('cfg.id IS NOT NULL')
} else if (status === 'unconfigured') {
conditions.push('cfg.id IS NULL')
}
if (platformKey) {
params.push(platformKey)
conditions.push(`cfg.platform_key = $${params.length}`)
}
if (provider) {
params.push(provider)
conditions.push(`EXISTS (
SELECT 1 FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_mode = 'manual'
AND o.provider = $${params.length}
AND COALESCE(NULLIF(oi.spec_json->>'skuNick', ''), NULLIF(oi.spec_json->>'outSkuSn', '')) = r.sku_key
)`)
}
const where = conditions.join(' AND ')
const countResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM manual_sku_registry r
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
WHERE ${where}
`,
params,
)
const listParams = [...params, pageSize, (page - 1) * pageSize]
const result = await query<ManualSkuRow>(
`
SELECT
r.id, r.sku_key, r.display_name, r.shop_count, r.order_count, r.item_count,
r.revenue_fen, r.first_seen_date, r.last_seen_date,
r.scan_from_date, r.scan_to_date,
cfg.id AS config_id,
cfg.platform_key AS config_platform_key,
cfg.credit_quantity AS config_credit_quantity,
cfg.cost_mode AS config_cost_mode,
cfg.canonical_product_id AS config_canonical_product_id,
cfg.enabled AS config_enabled
FROM manual_sku_registry r
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
WHERE ${where}
ORDER BY r.item_count DESC, r.sku_key ASC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
`,
listParams,
)
return {
items: result.rows.map((row) => mapManualSku(row, unitPrices)),
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
}
}
/** 清单总览:已配置 / 未配置 / 覆盖单量。 */
export async function getManualSkuOverview() {
const result = await query<{
total_sku: number
configured_sku: number
unconfigured_sku: number
total_items: number
configured_items: number
unconfigured_items: number
last_scan_at: string | null
}>(
`
SELECT
COUNT(*)::int AS total_sku,
COUNT(*) FILTER (WHERE cfg.id IS NOT NULL)::int AS configured_sku,
COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_sku,
COALESCE(SUM(r.item_count), 0)::bigint AS total_items,
COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NOT NULL), 0)::bigint
AS configured_items,
COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NULL), 0)::bigint
AS unconfigured_items,
MAX(r.updated_at) AS last_scan_at
FROM manual_sku_registry r
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
`,
)
const row = result.rows[0]
return {
totalSku: Number(row?.total_sku || 0),
configuredSku: Number(row?.configured_sku || 0),
unconfiguredSku: Number(row?.unconfigured_sku || 0),
totalItems: Number(row?.total_items || 0),
configuredItems: Number(row?.configured_items || 0),
unconfiguredItems: Number(row?.unconfigured_items || 0),
lastScanAt: row?.last_scan_at || null,
}
}
/** 保存(新增或更新)一个人工 SKU 的履约配置。 */
export async function saveManualFulfillmentConfig(payload: JsonObject) {
const skuKey = String(payload.skuKey ?? payload.sku_key ?? '').trim()
if (!skuKey) {
throw createHttpError('内部 SKU 不能为空', {
statusCode: 400,
errorCode: 'manual_config_sku_key_required',
})
}
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
const creditQuantity = normalizeCreditQuantity(payload.creditQuantity ?? payload.credit_quantity)
const canonicalProductId = normalizeOptionalId(
payload.canonicalProductId ?? payload.canonical_product_id,
)
const enabled = payload.enabled === false ? false : true
const note = optionalText(payload.note, 300)
// 消耗积分为 0 = 成本不固定,客服确认履约时手填。
const costMode = creditQuantity > 0 ? 'fixed' : 'variable'
const unitPrices = await loadPlatformUnitPriceFen()
const now = nowIso()
const result = await query<{
id: number
sku_key: string
canonical_product_id: number | null
platform_key: string
credit_quantity: number
cost_mode: string
enabled: boolean
note: string
created_at: string
updated_at: string
}>(
`
INSERT INTO manual_fulfillment_configs (
sku_key, canonical_product_id, platform_key, credit_quantity,
cost_mode, enabled, note, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $8
)
ON CONFLICT (sku_key) DO UPDATE
SET canonical_product_id = EXCLUDED.canonical_product_id,
platform_key = EXCLUDED.platform_key,
credit_quantity = EXCLUDED.credit_quantity,
cost_mode = EXCLUDED.cost_mode,
enabled = EXCLUDED.enabled,
note = EXCLUDED.note,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[skuKey, canonicalProductId, platformKey, creditQuantity, costMode, enabled, note, now],
)
const config = requiredRow(result.rows[0], '保存人工履约配置失败')
await syncRegistryConfigId(config.sku_key, config.id)
return mapManualConfig(config, unitPrices)
}
/** 删除一份人工履约配置(SKU 回到未配置状态)。 */
export async function deleteManualFulfillmentConfig(skuKey: string) {
const key = String(skuKey || '').trim()
if (!key) {
throw createHttpError('内部 SKU 不能为空', {
statusCode: 400,
errorCode: 'manual_config_sku_key_required',
})
}
const deleted = await query(
`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1 RETURNING id`,
[key],
)
await syncRegistryConfigId(key, null)
return { skuKey: key, deleted: deleted.rowCount || 0 }
}
/**
* 重新匹配:把已配置项回填到历史人工订单项。
*
* 幂等:只填空值,已回填的不动。
*/
export async function rematchManualFulfillments(input: Record<string, unknown> = {}) {
const skuKey = String(input.skuKey ?? input.sku_key ?? '').trim()
const onlyUnmatched = input.onlyUnmatched !== false
const now = nowIso()
const result = await query<{ updated_count: number }>(
`
WITH target AS (
SELECT
oi.id AS order_item_id,
cfg.id AS config_id,
cfg.canonical_product_id
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN manual_fulfillment_configs cfg
ON cfg.sku_key = COALESCE(
NULLIF(oi.spec_json->>'skuNick', ''),
NULLIF(oi.spec_json->>'outSkuSn', '')
)
WHERE oi.fulfillment_mode = 'manual'
AND cfg.enabled = TRUE
AND ($1 = '' OR cfg.sku_key = $1)
AND ($2 = FALSE OR oi.fulfillment_config_id IS NULL)
)
UPDATE order_items oi
SET fulfillment_config_id = target.config_id,
canonical_product_id = COALESCE(target.canonical_product_id, oi.canonical_product_id),
updated_at = $3
FROM target
WHERE oi.id = target.order_item_id
RETURNING 1
`,
[skuKey, onlyUnmatched, now],
)
// 配置生效后,把 SKU 对应的成本版本挂上(若已配置商品)。
await attachCostVersionsForManualConfigs()
return {
updatedCount: result.rowCount || 0,
skuKey,
}
}
/**
* 为已配置的人工 SKU 生成/刷新成本版本(按 1 积分 = 1 分钱换算)。
*
* 成本版本是「成本金额」的唯一真源;积分消耗量记在配置里,
* 两者按固定换算率保持一致。
*/
async function attachCostVersionsForManualConfigs() {
const costItemResult = await query<{ id: number }>(
`SELECT id FROM cost_items WHERE code = 'sys_manual_service' AND status = 'active' LIMIT 1`,
)
const costItemId = Number(costItemResult.rows[0]?.id || 0)
if (!costItemId) return
await query(
`
INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, source,
status, change_reason, created_at, updated_at
)
SELECT
$1, 'product', '', '', cfg.canonical_product_id, NULL,
(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint), 0,
'1970-01-01T00:00:00+08:00', 'manual', 'active',
'人工履约配置:积分按平台换算率折算',
NOW(), NOW()
FROM manual_fulfillment_configs cfg
JOIN fulfillment_profiles fp
ON fp.profile_key = 'manual_' || cfg.platform_key
WHERE cfg.enabled = TRUE
AND cfg.credit_quantity > 0
AND cfg.canonical_product_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM cost_versions v
WHERE v.cost_item_id = $1
AND v.scope_type = 'product'
AND v.canonical_product_id = cfg.canonical_product_id
AND v.status = 'active'
)
`,
[costItemId],
)
}
/** 配置保存/删除后同步登记表上的 config_id。 */
async function syncRegistryConfigId(skuKey: string, configId: number | null) {
await query(`UPDATE manual_sku_registry SET config_id = $2, updated_at = $3 WHERE sku_key = $1`, [
skuKey,
configId,
nowIso(),
])
}
/** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */
export async function getManualFulfillmentConfig(skuKey: string) {
const unitPrices = await loadPlatformUnitPriceFen()
const key = String(skuKey || '').trim()
const result = await query<{
id: number
sku_key: string
canonical_product_id: number | null
platform_key: string
credit_quantity: number
cost_mode: string
enabled: boolean
note: string
created_at: string
updated_at: string
}>(`SELECT * FROM manual_fulfillment_configs WHERE sku_key = $1 LIMIT 1`, [key])
const row = result.rows[0]
if (!row) return null
return mapManualConfig(row, unitPrices)
}
/** 平台选项(前端下拉)。 */
export async function listManualPlatformOptions() {
const unitPrices = await loadPlatformUnitPriceFen()
return {
items: MANUAL_PLATFORM_KEYS.map((key) => ({
key,
label: PLATFORM_LABELS[key],
isCredit: key !== 'fund_cash',
creditUnitPriceFen: unitPrices[key],
})),
}
}
function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, number>) {
const creditQuantity = Number(row.config_credit_quantity || 0)
const platformKey = (row.config_platform_key || '') as ManualPlatformKey
const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
return {
id: Number(row.id),
skuKey: row.sku_key,
displayName: row.display_name || '',
shopCount: Number(row.shop_count || 0),
orderCount: Number(row.order_count || 0),
itemCount: Number(row.item_count || 0),
revenueFen: Number(row.revenue_fen || 0),
firstSeenDate: row.first_seen_date || null,
lastSeenDate: row.last_seen_date || null,
scanFromDate: row.scan_from_date || null,
scanToDate: row.scan_to_date || null,
configured: row.config_id !== null,
config: row.config_id
? {
id: Number(row.config_id),
skuKey: row.sku_key,
platformKey: row.config_platform_key || '',
platformLabel: PLATFORM_LABELS[platformKey] || '',
creditQuantity,
costMode: row.config_cost_mode || 'variable',
costFen: creditQuantity * unitPriceFen,
creditUnitPriceFen: unitPriceFen,
canonicalProductId: row.config_canonical_product_id
? Number(row.config_canonical_product_id)
: null,
enabled: row.config_enabled !== false,
}
: null,
}
}
function mapManualConfig(
row: {
id: number
sku_key: string
canonical_product_id: number | null
platform_key: string
credit_quantity: number
cost_mode: string
enabled: boolean
note: string
created_at: string
updated_at: string
},
unitPrices: Record<ManualPlatformKey, number>,
) {
const creditQuantity = Number(row.credit_quantity || 0)
const platformKey = row.platform_key as ManualPlatformKey
const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
return {
id: Number(row.id),
skuKey: row.sku_key,
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
platformKey: row.platform_key,
platformLabel: PLATFORM_LABELS[platformKey] || row.platform_key,
creditQuantity,
costMode: row.cost_mode,
costFen: creditQuantity * unitPriceFen,
creditUnitPriceFen: unitPriceFen,
enabled: row.enabled,
note: row.note || '',
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function normalizePlatformKey(value: unknown): ManualPlatformKey {
const text = String(value || '').trim()
if (!MANUAL_PLATFORM_KEYS.includes(text as ManualPlatformKey)) {
throw createHttpError('履约渠道无效', {
statusCode: 400,
errorCode: 'manual_config_platform_invalid',
})
}
return text as ManualPlatformKey
}
function normalizeCreditQuantity(value: unknown) {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
throw createHttpError('消耗积分必须是非负整数', {
statusCode: 400,
errorCode: 'manual_config_credit_invalid',
})
}
return parsed
}
function normalizeDate(value: unknown, label: string) {
const text = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
throw createHttpError(`${label}格式无效(应为 YYYY-MM-DD)`, {
statusCode: 400,
errorCode: 'manual_sku_date_invalid',
})
}
return text
}
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: 'manual_sku_pagination_invalid',
})
}
return parsed
}
function normalizeOptionalId(value: unknown) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
function optionalText(value: unknown, maxLength: number) {
return String(value || '')
.trim()
.slice(0, maxLength)
}
function escapeLike(value: string) {
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
}
function requiredRow<T>(row: T | undefined, message: string): T {
if (!row) {
throw createHttpError(message, {
statusCode: 404,
errorCode: 'manual_config_not_found',
})
}
return row
}