增加飞飞领取短链
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import {
|
||||
createShortLink,
|
||||
findShortLinkByCode,
|
||||
markShortLinkVisited,
|
||||
} from '../../repositories/short-link-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { addHours, nowIso } from '../../utils/time.js'
|
||||
import type { ShortLinkRow } from '../../types/repository/rows.js'
|
||||
|
||||
const SHORT_LINK_CODE_LENGTH = 10
|
||||
const SHORT_LINK_MAX_CREATE_ATTEMPTS = 5
|
||||
const SHORT_LINK_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
const DEFAULT_SHORT_LINK_TTL_HOURS = 24 * 30
|
||||
|
||||
export const SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM = 'kuaishou_feifei_claim'
|
||||
|
||||
export type ShortLinkPayload = {
|
||||
code: string
|
||||
url: string
|
||||
targetUrl: string
|
||||
expiresAt: string | null
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export async function ensureShortLinkForTarget(
|
||||
targetUrl: string,
|
||||
options: {
|
||||
source?: string
|
||||
taskId?: number | string | null
|
||||
expiresAt?: string | null
|
||||
} = {},
|
||||
): Promise<ShortLinkPayload> {
|
||||
const normalizedTargetUrl = normalizeTargetUrl(targetUrl)
|
||||
const source = String(options.source || '').trim()
|
||||
const targetUrlHash = hashTargetUrl(normalizedTargetUrl)
|
||||
const now = nowIso()
|
||||
const expiresAt = options.expiresAt !== undefined
|
||||
? normalizeNullableIso(options.expiresAt)
|
||||
: addHours(now, DEFAULT_SHORT_LINK_TTL_HOURS)
|
||||
|
||||
for (let attempt = 1; attempt <= SHORT_LINK_MAX_CREATE_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const row = await createShortLink({
|
||||
code: generateShortLinkCode(),
|
||||
targetUrl: normalizedTargetUrl,
|
||||
targetUrlHash,
|
||||
source,
|
||||
taskId: options.taskId ?? null,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!row) {
|
||||
break
|
||||
}
|
||||
|
||||
return mapShortLinkPayload(row)
|
||||
} catch (error) {
|
||||
if (attempt < SHORT_LINK_MAX_CREATE_ATTEMPTS && isShortLinkCodeConflict(error)) {
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError('短链创建失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'short_link_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveShortLinkTarget(code: string): Promise<ShortLinkRow> {
|
||||
const normalizedCode = normalizeShortLinkCode(code)
|
||||
const row = await findShortLinkByCode(normalizedCode)
|
||||
|
||||
if (!row) {
|
||||
throw createHttpError('短链不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'short_link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (isShortLinkExpired(row)) {
|
||||
throw createHttpError('短链已过期', {
|
||||
statusCode: 410,
|
||||
errorCode: 'short_link_expired',
|
||||
})
|
||||
}
|
||||
|
||||
await markShortLinkVisited(row.code, nowIso())
|
||||
return row
|
||||
}
|
||||
|
||||
export function buildShortLinkUrl(code: string): string {
|
||||
const normalizedCode = normalizeShortLinkCode(code)
|
||||
const baseUrl = resolveShortLinkBaseUrl()
|
||||
return `${baseUrl.replace(/\/+$/, '')}/s/${normalizedCode}`
|
||||
}
|
||||
|
||||
export function normalizeShortLinkPayload(value: unknown): ShortLinkPayload | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>
|
||||
const code = String(source.code || '').trim()
|
||||
const url = String(source.url || '').trim()
|
||||
const targetUrl = String(source.targetUrl || '').trim()
|
||||
if (!code || !url || !targetUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: ShortLinkPayload = {
|
||||
code,
|
||||
url,
|
||||
targetUrl,
|
||||
expiresAt: normalizeNullableIso(source.expiresAt),
|
||||
}
|
||||
const createdAt = String(source.createdAt || '').trim()
|
||||
if (createdAt) {
|
||||
payload.createdAt = createdAt
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function mapShortLinkPayload(row: ShortLinkRow): ShortLinkPayload {
|
||||
return {
|
||||
code: row.code,
|
||||
url: buildShortLinkUrl(row.code),
|
||||
targetUrl: row.target_url,
|
||||
expiresAt: row.expires_at || null,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTargetUrl(value: string): string {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
throw createHttpError('短链目标地址不能为空', {
|
||||
statusCode: 400,
|
||||
errorCode: 'short_link_target_required',
|
||||
})
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(text)
|
||||
} catch {
|
||||
throw createHttpError('短链目标地址无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'short_link_target_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw createHttpError('短链目标地址只支持 HTTP/HTTPS', {
|
||||
statusCode: 400,
|
||||
errorCode: 'short_link_target_protocol_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function resolveShortLinkBaseUrl(): string {
|
||||
const claimBaseUrl = String(runtimeConfig.orders?.claimBaseUrl || '').trim()
|
||||
if (!claimBaseUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(claimBaseUrl)
|
||||
return url.origin
|
||||
} catch {
|
||||
return claimBaseUrl.replace(/#.*$/, '').replace(/\/+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeShortLinkCode(value: unknown): string {
|
||||
const code = String(value || '').trim()
|
||||
if (!/^[0-9A-Za-z_-]{4,64}$/.test(code)) {
|
||||
throw createHttpError('短链编码无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'short_link_code_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
function generateShortLinkCode(): string {
|
||||
const bytes = crypto.randomBytes(SHORT_LINK_CODE_LENGTH)
|
||||
let output = ''
|
||||
for (const byte of bytes) {
|
||||
output += SHORT_LINK_ALPHABET[byte % SHORT_LINK_ALPHABET.length] || '0'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function hashTargetUrl(targetUrl: string): string {
|
||||
return crypto.createHash('sha256').update(targetUrl, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function normalizeNullableIso(value: unknown): string | null {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(text)
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null
|
||||
}
|
||||
|
||||
function isShortLinkExpired(row: ShortLinkRow): boolean {
|
||||
if (!row.expires_at) {
|
||||
return false
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(row.expires_at)
|
||||
return Number.isFinite(timestamp) && timestamp <= Date.now()
|
||||
}
|
||||
|
||||
function isShortLinkCodeConflict(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('short_links_code') || marker.includes('code')
|
||||
}
|
||||
Reference in New Issue
Block a user