diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index f3265255..8edd8991 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -1,3 +1,5 @@ +import crypto from 'node:crypto' + import { query } from '../db/client.js' import type { KuaishouIndustryVoucherUpdatePatch, @@ -11,16 +13,42 @@ type VoucherPatchColumn = { 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( input: KuaishouIndustryVoucherUpsertInput, +): Promise { + 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 { const result = await query( ` - WITH next_id AS ( - SELECT nextval(pg_get_serial_sequence('kuaishou_industry_vouchers', 'id')) AS id - ) INSERT INTO kuaishou_industry_vouchers ( - id, voucher_code, oid, order_id, @@ -38,9 +66,7 @@ export async function upsertKuaishouIndustryVoucher( created_at, updated_at ) - SELECT - next_id.id, - next_id.id::text, + VALUES ( $1, $2, $3, @@ -50,13 +76,14 @@ export async function upsertKuaishouIndustryVoucher( $7, $8, $9, - $10::jsonb, - $11, + $10, + $11::jsonb, $12, - $13::jsonb, - $14, - $15 - FROM next_id + $13, + $14::jsonb, + $15, + $16 + ) ON CONFLICT (oid, unit_index) DO UPDATE SET token = CASE @@ -72,6 +99,7 @@ export async function upsertKuaishouIndustryVoucher( RETURNING * `, [ + voucherCode, input.oid, normalizeNullableId(input.orderId), normalizeNullableId(input.taskId), @@ -135,7 +163,8 @@ export async function findKuaishouIndustryVoucherByCode( 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' : '' if (normalizedOid) { params.push(normalizedOid) @@ -145,7 +174,7 @@ export async function findKuaishouIndustryVoucherByCode( ` SELECT * FROM kuaishou_industry_vouchers - WHERE voucher_code = $1 + WHERE voucher_code = ANY($1::text[]) ${oidFilter} LIMIT 1 `, @@ -252,6 +281,42 @@ function normalizeVoucherPatchColumns( 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 diff --git a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts index 676bea13..cf61ebda 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts @@ -1,30 +1,58 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { generateKuaishouIndustryVoucherCode } from '../../../repositories/kuaishou-industry-voucher-repo.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', () => { const nowMs = 1783394218325 - const validity = resolveKuaishouIndustryVoucherValidity({ - certActualStartTime: 0, - certActualEndTime: 0, - certStartTime: 0, - certEndTime: 0, - certExpDays: 3, - }, nowMs) + const validity = resolveKuaishouIndustryVoucherValidity( + { + certActualStartTime: 0, + certActualEndTime: 0, + certStartTime: 0, + certEndTime: 0, + certExpDays: 3, + }, + nowMs, + ) assert.equal(validity.validStartTime, nowMs) assert.equal(validity.validEndTime, nowMs + 3 * 86_400_000) }) test('resolveKuaishouIndustryVoucherValidity prefers actual certificate time range', () => { - const validity = resolveKuaishouIndustryVoucherValidity({ - certActualStartTime: 1000, - certActualEndTime: 5000, - certStartTime: 2000, - certEndTime: 6000, - certExpDays: 3, - }, 9000) + const validity = resolveKuaishouIndustryVoucherValidity( + { + certActualStartTime: 1000, + certActualEndTime: 5000, + certStartTime: 2000, + certEndTime: 6000, + certExpDays: 3, + }, + 9000, + ) assert.equal(validity.validStartTime, 1000) assert.equal(validity.validEndTime, 5000)