优化电子凭证核销码生成
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
import { query } from '../db/client.js'
|
import { query } from '../db/client.js'
|
||||||
import type {
|
import type {
|
||||||
KuaishouIndustryVoucherUpdatePatch,
|
KuaishouIndustryVoucherUpdatePatch,
|
||||||
@@ -11,16 +13,42 @@ type VoucherPatchColumn = {
|
|||||||
cast?: string
|
cast?: 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(
|
export async function upsertKuaishouIndustryVoucher(
|
||||||
input: KuaishouIndustryVoucherUpsertInput,
|
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> {
|
): Promise<KuaishouIndustryVoucherRow | null> {
|
||||||
const result = await query<KuaishouIndustryVoucherRow>(
|
const result = await query<KuaishouIndustryVoucherRow>(
|
||||||
`
|
`
|
||||||
WITH next_id AS (
|
|
||||||
SELECT nextval(pg_get_serial_sequence('kuaishou_industry_vouchers', 'id')) AS id
|
|
||||||
)
|
|
||||||
INSERT INTO kuaishou_industry_vouchers (
|
INSERT INTO kuaishou_industry_vouchers (
|
||||||
id,
|
|
||||||
voucher_code,
|
voucher_code,
|
||||||
oid,
|
oid,
|
||||||
order_id,
|
order_id,
|
||||||
@@ -38,9 +66,7 @@ export async function upsertKuaishouIndustryVoucher(
|
|||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
SELECT
|
VALUES (
|
||||||
next_id.id,
|
|
||||||
next_id.id::text,
|
|
||||||
$1,
|
$1,
|
||||||
$2,
|
$2,
|
||||||
$3,
|
$3,
|
||||||
@@ -50,13 +76,14 @@ export async function upsertKuaishouIndustryVoucher(
|
|||||||
$7,
|
$7,
|
||||||
$8,
|
$8,
|
||||||
$9,
|
$9,
|
||||||
$10::jsonb,
|
$10,
|
||||||
$11,
|
$11::jsonb,
|
||||||
$12,
|
$12,
|
||||||
$13::jsonb,
|
$13,
|
||||||
$14,
|
$14::jsonb,
|
||||||
$15
|
$15,
|
||||||
FROM next_id
|
$16
|
||||||
|
)
|
||||||
ON CONFLICT (oid, unit_index) DO UPDATE
|
ON CONFLICT (oid, unit_index) DO UPDATE
|
||||||
SET
|
SET
|
||||||
token = CASE
|
token = CASE
|
||||||
@@ -72,6 +99,7 @@ export async function upsertKuaishouIndustryVoucher(
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
|
voucherCode,
|
||||||
input.oid,
|
input.oid,
|
||||||
normalizeNullableId(input.orderId),
|
normalizeNullableId(input.orderId),
|
||||||
normalizeNullableId(input.taskId),
|
normalizeNullableId(input.taskId),
|
||||||
@@ -135,7 +163,8 @@ export async function findKuaishouIndustryVoucherByCode(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const params: unknown[] = [normalizedCode]
|
const candidateCodes = Array.from(new Set([normalizedCode, normalizedCode.toUpperCase()]))
|
||||||
|
const params: unknown[] = [candidateCodes]
|
||||||
const oidFilter = normalizedOid ? 'AND oid = $2' : ''
|
const oidFilter = normalizedOid ? 'AND oid = $2' : ''
|
||||||
if (normalizedOid) {
|
if (normalizedOid) {
|
||||||
params.push(normalizedOid)
|
params.push(normalizedOid)
|
||||||
@@ -145,7 +174,7 @@ export async function findKuaishouIndustryVoucherByCode(
|
|||||||
`
|
`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM kuaishou_industry_vouchers
|
FROM kuaishou_industry_vouchers
|
||||||
WHERE voucher_code = $1
|
WHERE voucher_code = ANY($1::text[])
|
||||||
${oidFilter}
|
${oidFilter}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
@@ -252,6 +281,42 @@ function normalizeVoucherPatchColumns(
|
|||||||
return columns
|
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 {
|
function normalizeNullableId(value: unknown): number | null {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,30 +1,58 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { generateKuaishouIndustryVoucherCode } from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
import { resolveKuaishouIndustryVoucherValidity } from './voucher-service.js'
|
import { resolveKuaishouIndustryVoucherValidity } from './voucher-service.js'
|
||||||
|
|
||||||
|
test('generateKuaishouIndustryVoucherCode creates non-numeric stable voucher code', () => {
|
||||||
|
let requestedSize = 0
|
||||||
|
const code = generateKuaishouIndustryVoucherCode((size) => {
|
||||||
|
requestedSize = size
|
||||||
|
return Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(requestedSize, 10)
|
||||||
|
assert.equal(code.length, 19)
|
||||||
|
assert.match(code, /^KSV[0-9ABCDEFGHJKMNPQRSTVWXYZ]{16}$/)
|
||||||
|
assert.doesNotMatch(code, /^\d+$/)
|
||||||
|
assert.doesNotMatch(code, /[ILOU]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateKuaishouIndustryVoucherCode changes with random bytes', () => {
|
||||||
|
const first = generateKuaishouIndustryVoucherCode(() => Buffer.alloc(10, 0))
|
||||||
|
const second = generateKuaishouIndustryVoucherCode(() => Buffer.alloc(10, 1))
|
||||||
|
|
||||||
|
assert.notEqual(first, second)
|
||||||
|
})
|
||||||
|
|
||||||
test('resolveKuaishouIndustryVoucherValidity uses certExpDays when explicit times are zero', () => {
|
test('resolveKuaishouIndustryVoucherValidity uses certExpDays when explicit times are zero', () => {
|
||||||
const nowMs = 1783394218325
|
const nowMs = 1783394218325
|
||||||
const validity = resolveKuaishouIndustryVoucherValidity({
|
const validity = resolveKuaishouIndustryVoucherValidity(
|
||||||
certActualStartTime: 0,
|
{
|
||||||
certActualEndTime: 0,
|
certActualStartTime: 0,
|
||||||
certStartTime: 0,
|
certActualEndTime: 0,
|
||||||
certEndTime: 0,
|
certStartTime: 0,
|
||||||
certExpDays: 3,
|
certEndTime: 0,
|
||||||
}, nowMs)
|
certExpDays: 3,
|
||||||
|
},
|
||||||
|
nowMs,
|
||||||
|
)
|
||||||
|
|
||||||
assert.equal(validity.validStartTime, nowMs)
|
assert.equal(validity.validStartTime, nowMs)
|
||||||
assert.equal(validity.validEndTime, nowMs + 3 * 86_400_000)
|
assert.equal(validity.validEndTime, nowMs + 3 * 86_400_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('resolveKuaishouIndustryVoucherValidity prefers actual certificate time range', () => {
|
test('resolveKuaishouIndustryVoucherValidity prefers actual certificate time range', () => {
|
||||||
const validity = resolveKuaishouIndustryVoucherValidity({
|
const validity = resolveKuaishouIndustryVoucherValidity(
|
||||||
certActualStartTime: 1000,
|
{
|
||||||
certActualEndTime: 5000,
|
certActualStartTime: 1000,
|
||||||
certStartTime: 2000,
|
certActualEndTime: 5000,
|
||||||
certEndTime: 6000,
|
certStartTime: 2000,
|
||||||
certExpDays: 3,
|
certEndTime: 6000,
|
||||||
}, 9000)
|
certExpDays: 3,
|
||||||
|
},
|
||||||
|
9000,
|
||||||
|
)
|
||||||
|
|
||||||
assert.equal(validity.validStartTime, 1000)
|
assert.equal(validity.validStartTime, 1000)
|
||||||
assert.equal(validity.validEndTime, 5000)
|
assert.equal(validity.validEndTime, 5000)
|
||||||
|
|||||||
Reference in New Issue
Block a user