Files
order_site/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts
T
2026-08-18 12:05:12 +08:00

587 lines
16 KiB
TypeScript

import crypto from 'node:crypto'
import { query } from '../db/client.js'
import type {
KuaishouIndustryVoucherUpdatePatch,
KuaishouIndustryVoucherUpsertInput,
} from '../types/repository/inputs.js'
import type { KuaishouIndustryVoucherRow } from '../types/repository/rows.js'
type VoucherPatchColumn = {
column: string
value: unknown
cast?: string
}
export type KuaishouIndustryVoucherAdminListQuery = {
oid?: string
voucherCode?: string
taskId?: number | string
sellerId?: string
status?: string
page?: number | string
pageSize?: number | string
}
const VOUCHER_CODE_PREFIX = 'KSV'
const VOUCHER_CODE_RANDOM_BYTES = 10
const VOUCHER_CODE_MAX_GENERATE_ATTEMPTS = 5
const CROCKFORD_BASE32_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
export async function upsertKuaishouIndustryVoucher(
input: KuaishouIndustryVoucherUpsertInput,
): Promise<KuaishouIndustryVoucherRow | null> {
for (let attempt = 1; attempt <= VOUCHER_CODE_MAX_GENERATE_ATTEMPTS; attempt += 1) {
try {
return await insertKuaishouIndustryVoucher(input, generateKuaishouIndustryVoucherCode())
} catch (error) {
if (attempt < VOUCHER_CODE_MAX_GENERATE_ATTEMPTS && isVoucherCodeUniqueViolation(error)) {
continue
}
throw error
}
}
return null
}
export function generateKuaishouIndustryVoucherCode(
randomBytes: (size: number) => Uint8Array = crypto.randomBytes,
): string {
return `${VOUCHER_CODE_PREFIX}${encodeCrockfordBase32(randomBytes(VOUCHER_CODE_RANDOM_BYTES))}`
}
async function insertKuaishouIndustryVoucher(
input: KuaishouIndustryVoucherUpsertInput,
voucherCode: string,
): Promise<KuaishouIndustryVoucherRow | null> {
const result = await query<KuaishouIndustryVoucherRow>(
`
INSERT INTO kuaishou_industry_vouchers (
voucher_code,
oid,
order_id,
task_id,
unit_index,
seller_id,
token,
eticket_type,
status,
valid_start_time,
valid_end_time,
consume_serial_num,
consume_details_json,
consumed_at,
destroyed_at,
send_callback_status,
send_callback_attempt_count,
send_callback_last_error,
send_callback_response_json,
send_callback_sent_at,
raw_payload_json,
created_at,
updated_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12,
$13::jsonb,
$14,
$15,
$16,
$17,
$18,
$19::jsonb,
$20,
$21::jsonb,
$22,
$23
)
ON CONFLICT (oid, unit_index) DO UPDATE
SET
seller_id = CASE
WHEN EXCLUDED.seller_id <> '' THEN EXCLUDED.seller_id
ELSE kuaishou_industry_vouchers.seller_id
END,
token = CASE
WHEN EXCLUDED.token <> '' THEN EXCLUDED.token
ELSE kuaishou_industry_vouchers.token
END,
eticket_type = CASE
WHEN EXCLUDED.eticket_type <> '' THEN EXCLUDED.eticket_type
ELSE kuaishou_industry_vouchers.eticket_type
END,
order_id = COALESCE(kuaishou_industry_vouchers.order_id, EXCLUDED.order_id),
task_id = COALESCE(kuaishou_industry_vouchers.task_id, EXCLUDED.task_id),
valid_start_time = EXCLUDED.valid_start_time,
valid_end_time = EXCLUDED.valid_end_time,
send_callback_status = CASE
WHEN kuaishou_industry_vouchers.send_callback_status = 'success'
THEN kuaishou_industry_vouchers.send_callback_status
ELSE EXCLUDED.send_callback_status
END,
send_callback_attempt_count = CASE
WHEN kuaishou_industry_vouchers.send_callback_status = 'success'
THEN kuaishou_industry_vouchers.send_callback_attempt_count
ELSE EXCLUDED.send_callback_attempt_count
END,
send_callback_last_error = CASE
WHEN kuaishou_industry_vouchers.send_callback_status = 'success'
THEN kuaishou_industry_vouchers.send_callback_last_error
ELSE EXCLUDED.send_callback_last_error
END,
send_callback_response_json = CASE
WHEN kuaishou_industry_vouchers.send_callback_status = 'success'
THEN kuaishou_industry_vouchers.send_callback_response_json
ELSE EXCLUDED.send_callback_response_json
END,
send_callback_sent_at = CASE
WHEN kuaishou_industry_vouchers.send_callback_status = 'success'
THEN kuaishou_industry_vouchers.send_callback_sent_at
ELSE EXCLUDED.send_callback_sent_at
END,
raw_payload_json = EXCLUDED.raw_payload_json,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
voucherCode,
input.oid,
normalizeNullableId(input.orderId),
normalizeNullableId(input.taskId),
input.unitIndex,
input.sellerId || '',
input.token || '',
input.eticketType || '',
input.status || 'UNUSED',
input.validStartTime || 0,
input.validEndTime || 0,
input.consumeSerialNum || '',
stringifyJson(input.consumeDetailsJson ?? []),
input.consumedAt || null,
input.destroyedAt || null,
input.sendCallbackStatus || 'success',
normalizeNonNegativeInteger(input.sendCallbackAttemptCount, 0),
input.sendCallbackLastError || '',
stringifyJson(input.sendCallbackResponseJson ?? {}),
input.sendCallbackSentAt || null,
stringifyJson(input.rawPayloadJson ?? {}),
input.createdAt,
input.updatedAt,
],
)
return result.rows[0] || null
}
export async function listKuaishouIndustryVouchersByOid(
oid: string,
): Promise<KuaishouIndustryVoucherRow[]> {
const result = await query<KuaishouIndustryVoucherRow>(
`
SELECT *
FROM kuaishou_industry_vouchers
WHERE oid = $1
ORDER BY unit_index ASC, id ASC
`,
[oid],
)
return result.rows
}
export type KuaishouIndustryVoucherWorkOrderSyncSource = {
oid: string
raw_payload_json: string | Record<string, unknown>
updated_at: string
}
export async function listKuaishouIndustryVoucherWorkOrderSyncSources(
limit = 100,
): Promise<KuaishouIndustryVoucherWorkOrderSyncSource[]> {
const result = await query<KuaishouIndustryVoucherWorkOrderSyncSource>(
`
SELECT oid, raw_payload_json, updated_at
FROM (
SELECT DISTINCT ON (oid) oid, raw_payload_json, updated_at, id
FROM kuaishou_industry_vouchers
WHERE raw_payload_json ? 'body'
ORDER BY oid, updated_at DESC, id DESC
) AS latest_source
ORDER BY updated_at DESC, id DESC
LIMIT $1
`,
[normalizePositiveLimit(limit, 100)],
)
return result.rows
}
export async function listKuaishouIndustryVouchersForSendCallbackRetry(
limit = 100,
maxAttempts = 8,
): Promise<KuaishouIndustryVoucherRow[]> {
const result = await query<KuaishouIndustryVoucherRow>(
`
SELECT *
FROM kuaishou_industry_vouchers
WHERE send_callback_status <> 'success'
AND COALESCE(send_callback_attempt_count, 0) < $2
ORDER BY COALESCE(send_callback_sent_at, created_at) ASC, id ASC
LIMIT $1
`,
[normalizePositiveLimit(limit, 100), Math.max(1, Math.trunc(Number(maxAttempts) || 8))],
)
return result.rows
}
export async function listKuaishouIndustryVouchersByTaskId(
taskId: number | string,
): Promise<KuaishouIndustryVoucherRow[]> {
const result = await query<KuaishouIndustryVoucherRow>(
`
SELECT *
FROM kuaishou_industry_vouchers
WHERE task_id = $1
ORDER BY unit_index ASC, id ASC
`,
[Number(taskId)],
)
return result.rows
}
export type KuaishouIndustryVoucherAdminListRow = KuaishouIndustryVoucherRow & {
sku_code?: string | null
sku_name?: string | null
task_no?: string | null
}
export async function listKuaishouIndustryVouchersForAdmin(
input: KuaishouIndustryVoucherAdminListQuery = {},
): Promise<{
items: KuaishouIndustryVoucherAdminListRow[]
total: number
page: number
pageSize: number
}> {
const page = normalizePositiveLimit(input.page, 1)
const pageSize = Math.min(normalizePositiveLimit(input.pageSize, 50), 200)
const where: string[] = []
const params: unknown[] = []
const oid = String(input.oid || '').trim()
if (oid) {
params.push(oid)
where.push(`v.oid = $${params.length}`)
}
const voucherCode = String(input.voucherCode || '').trim()
if (voucherCode) {
params.push(Array.from(new Set([voucherCode, voucherCode.toUpperCase()])))
where.push(`v.voucher_code = ANY($${params.length}::text[])`)
}
const taskId = Number(input.taskId || 0)
if (Number.isFinite(taskId) && taskId > 0) {
params.push(Math.trunc(taskId))
where.push(`v.task_id = $${params.length}`)
}
const sellerId = String(input.sellerId || '').trim()
if (sellerId) {
params.push(sellerId)
where.push(`v.seller_id = $${params.length}`)
}
const status = String(input.status || '')
.trim()
.toUpperCase()
if (status) {
params.push(status)
where.push(`v.status = $${params.length}`)
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
const totalResult = await query<{ total: string | number }>(
`
SELECT COUNT(*) AS total
FROM kuaishou_industry_vouchers v
${whereSql}
`,
params,
)
const listParams = [...params, pageSize, (page - 1) * pageSize]
// 关联任务/订单商品,便于运营后台展示「买的是什么」
const listResult = await query<KuaishouIndustryVoucherAdminListRow>(
`
SELECT
v.*,
COALESCE(oi_task.sku_code, oi_order.sku_code, '') AS sku_code,
COALESCE(oi_task.sku_name, oi_order.sku_name, '') AS sku_name,
COALESCE(ft.task_no, '') AS task_no
FROM kuaishou_industry_vouchers v
LEFT JOIN fulfillment_tasks ft ON ft.id = v.task_id
LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id
LEFT JOIN LATERAL (
SELECT oi.sku_code, oi.sku_name
FROM order_items oi
WHERE v.order_id IS NOT NULL
AND oi.order_id = v.order_id
ORDER BY oi.id ASC
LIMIT 1
) oi_order ON true
${whereSql}
ORDER BY v.updated_at DESC, v.id DESC
LIMIT $${params.length + 1}
OFFSET $${params.length + 2}
`,
listParams,
)
return {
items: listResult.rows,
total: Number(totalResult.rows[0]?.total || 0) || 0,
page,
pageSize,
}
}
export async function findKuaishouIndustryVoucherByCode(
voucherCode: string,
oid = '',
): Promise<KuaishouIndustryVoucherRow | null> {
const normalizedCode = String(voucherCode || '').trim()
const normalizedOid = String(oid || '').trim()
if (!normalizedCode) {
return null
}
const candidateCodes = Array.from(new Set([normalizedCode, normalizedCode.toUpperCase()]))
const params: unknown[] = [candidateCodes]
const oidFilter = normalizedOid ? 'AND oid = $2' : ''
if (normalizedOid) {
params.push(normalizedOid)
}
const result = await query<KuaishouIndustryVoucherRow>(
`
SELECT *
FROM kuaishou_industry_vouchers
WHERE voucher_code = ANY($1::text[])
${oidFilter}
LIMIT 1
`,
params,
)
return result.rows[0] || null
}
export async function updateKuaishouIndustryVoucherByCode(
voucherCode: string,
patch: KuaishouIndustryVoucherUpdatePatch,
): Promise<KuaishouIndustryVoucherRow | null> {
const normalizedCode = String(voucherCode || '').trim()
if (!normalizedCode) {
return null
}
const columns = normalizeVoucherPatchColumns(patch)
if (columns.length === 0) {
return findKuaishouIndustryVoucherByCode(normalizedCode)
}
const assignments = columns
.map((item, index) => `${item.column} = $${index + 1}${item.cast || ''}`)
.join(', ')
const params = columns.map((item) => item.value)
params.push(normalizedCode)
const result = await query<KuaishouIndustryVoucherRow>(
`
UPDATE kuaishou_industry_vouchers
SET ${assignments}
WHERE voucher_code = $${params.length}
RETURNING *
`,
params,
)
return result.rows[0] || null
}
function normalizeVoucherPatchColumns(
patch: KuaishouIndustryVoucherUpdatePatch,
): VoucherPatchColumn[] {
const columns: VoucherPatchColumn[] = []
if (patch.orderId !== undefined) {
columns.push({ column: 'order_id', value: normalizeNullableId(patch.orderId) })
}
if (patch.taskId !== undefined) {
columns.push({ column: 'task_id', value: normalizeNullableId(patch.taskId) })
}
if (patch.token !== undefined) {
columns.push({ column: 'token', value: patch.token || '' })
}
if (patch.sellerId !== undefined) {
columns.push({ column: 'seller_id', value: patch.sellerId || '' })
}
if (patch.eticketType !== undefined) {
columns.push({ column: 'eticket_type', value: patch.eticketType || '' })
}
if (patch.status !== undefined) {
columns.push({ column: 'status', value: patch.status || 'UNUSED' })
}
if (patch.validStartTime !== undefined) {
columns.push({ column: 'valid_start_time', value: patch.validStartTime || 0 })
}
if (patch.validEndTime !== undefined) {
columns.push({ column: 'valid_end_time', value: patch.validEndTime || 0 })
}
if (patch.consumeSerialNum !== undefined) {
columns.push({ column: 'consume_serial_num', value: patch.consumeSerialNum || '' })
}
if (patch.consumeDetailsJson !== undefined) {
columns.push({
column: 'consume_details_json',
value: stringifyJson(patch.consumeDetailsJson ?? []),
cast: '::jsonb',
})
}
if (patch.consumedAt !== undefined) {
columns.push({ column: 'consumed_at', value: patch.consumedAt || null })
}
if (patch.destroyedAt !== undefined) {
columns.push({ column: 'destroyed_at', value: patch.destroyedAt || null })
}
if (patch.sendCallbackStatus !== undefined) {
columns.push({ column: 'send_callback_status', value: patch.sendCallbackStatus || 'success' })
}
if (patch.sendCallbackAttemptCount !== undefined) {
columns.push({
column: 'send_callback_attempt_count',
value: normalizeNonNegativeInteger(patch.sendCallbackAttemptCount, 0),
})
}
if (patch.sendCallbackLastError !== undefined) {
columns.push({ column: 'send_callback_last_error', value: patch.sendCallbackLastError || '' })
}
if (patch.sendCallbackResponseJson !== undefined) {
columns.push({
column: 'send_callback_response_json',
value: stringifyJson(patch.sendCallbackResponseJson ?? {}),
cast: '::jsonb',
})
}
if (patch.sendCallbackSentAt !== undefined) {
columns.push({ column: 'send_callback_sent_at', value: patch.sendCallbackSentAt || null })
}
if (patch.rawPayloadJson !== undefined) {
columns.push({
column: 'raw_payload_json',
value: stringifyJson(patch.rawPayloadJson ?? {}),
cast: '::jsonb',
})
}
if (patch.updatedAt !== undefined) {
columns.push({ column: 'updated_at', value: patch.updatedAt })
}
return columns
}
function encodeCrockfordBase32(bytes: Uint8Array): string {
let bits = 0
let value = 0
let output = ''
for (const byte of bytes) {
value = (value << 8) | byte
bits += 8
while (bits >= 5) {
output += CROCKFORD_BASE32_ALPHABET[(value >>> (bits - 5)) & 31] || ''
bits -= 5
}
}
if (bits > 0) {
output += CROCKFORD_BASE32_ALPHABET[(value << (5 - bits)) & 31] || ''
}
return output
}
function isVoucherCodeUniqueViolation(error: unknown): boolean {
const current =
error && typeof error === 'object'
? (error as { code?: unknown; constraint?: unknown; detail?: unknown })
: {}
if (String(current.code || '') !== '23505') {
return false
}
const marker = `${String(current.constraint || '')} ${String(current.detail || '')}`
return marker.includes('voucher_code')
}
function normalizeNullableId(value: unknown): number | null {
if (value === null || value === undefined || value === '') {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function normalizeNonNegativeInteger(value: unknown, fallback: number) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function normalizePositiveLimit(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback
}
function stringifyJson(value: unknown): string {
if (typeof value === 'string') {
return value
}
return JSON.stringify(value ?? {})
}