增加飞飞领取短链

This commit is contained in:
yml2213
2026-07-07 21:13:37 +08:00
parent 4131d500f2
commit 58a902dd3b
12 changed files with 527 additions and 14 deletions
@@ -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')
}