diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index a4bd5a77..5d5ae4c1 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,6 +7,7 @@ import claimsRouter from "./routes/claims.js"; import kuaishouFeifeiRouter from "./routes/kuaishou-feifei.js"; import kuaishouIndustryRouter from "./routes/kuaishou-industry.js"; import open91Router from "./routes/open-91.js"; +import shortLinksRouter from "./routes/short-links.js"; import { accessLogMiddleware } from "./middleware/access-log.js"; import { createCorsMiddleware } from "./middleware/cors.js"; import { buildHealthPayload, type StartupState } from "./startup/state.js"; @@ -103,6 +104,7 @@ export function createApp({ app.use("/api/v1/open/kuaishou-feifei", kuaishouFeifeiRouter); app.use("/api/v1/claim", claimsRouter); app.use("/api/v1/admin", adminRouter); + app.use("/s", shortLinksRouter); app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { sendRouteError(res, err, "服务内部错误", "[global]"); diff --git a/apps/backend/src/db/migrations/003_short_links.sql b/apps/backend/src/db/migrations/003_short_links.sql new file mode 100644 index 00000000..2cd2b7e1 --- /dev/null +++ b/apps/backend/src/db/migrations/003_short_links.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS short_links ( + id BIGSERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + target_url TEXT NOT NULL, + target_url_hash TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL, + expires_at TIMESTAMPTZ, + visit_count INTEGER NOT NULL DEFAULT 0, + last_visited_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(source, task_id, target_url_hash) +); + +CREATE INDEX IF NOT EXISTS idx_short_links_code + ON short_links(code); + +CREATE INDEX IF NOT EXISTS idx_short_links_task_id + ON short_links(task_id); + +CREATE INDEX IF NOT EXISTS idx_short_links_expires_at + ON short_links(expires_at); diff --git a/apps/backend/src/repositories/short-link-repo.ts b/apps/backend/src/repositories/short-link-repo.ts new file mode 100644 index 00000000..c19de858 --- /dev/null +++ b/apps/backend/src/repositories/short-link-repo.ts @@ -0,0 +1,92 @@ +import { query } from '../db/client.js' +import type { ShortLinkCreateInput } from '../types/repository/inputs.js' +import type { ShortLinkRow } from '../types/repository/rows.js' + +export async function createShortLink(input: ShortLinkCreateInput): Promise { + const result = await query( + ` + INSERT INTO short_links ( + code, + target_url, + target_url_hash, + source, + task_id, + expires_at, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (source, task_id, target_url_hash) DO UPDATE + SET + target_url = EXCLUDED.target_url, + expires_at = EXCLUDED.expires_at, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + input.code, + input.targetUrl, + input.targetUrlHash, + input.source || '', + normalizeNullableId(input.taskId), + input.expiresAt || null, + input.createdAt, + input.updatedAt, + ], + ) + + return result.rows[0] || null +} + +export async function findShortLinkByCode(code: string): Promise { + const normalizedCode = String(code || '').trim() + if (!normalizedCode) { + return null + } + + const result = await query( + ` + SELECT * + FROM short_links + WHERE code = $1 + LIMIT 1 + `, + [normalizedCode], + ) + + return result.rows[0] || null +} + +export async function markShortLinkVisited( + code: string, + visitedAt: string, +): Promise { + const normalizedCode = String(code || '').trim() + if (!normalizedCode) { + return null + } + + const result = await query( + ` + UPDATE short_links + SET + visit_count = visit_count + 1, + last_visited_at = $2, + updated_at = $2 + WHERE code = $1 + RETURNING * + `, + [normalizedCode, visitedAt], + ) + + return result.rows[0] || null +} + +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 +} diff --git a/apps/backend/src/routes/short-links.ts b/apps/backend/src/routes/short-links.ts new file mode 100644 index 00000000..ec44cf48 --- /dev/null +++ b/apps/backend/src/routes/short-links.ts @@ -0,0 +1,34 @@ +import { Router } from 'express' + +import { resolveShortLinkTarget } from '../services/short-links/short-link-service.js' +import { createRequestId, logIntegration } from '../utils/logger.js' +import { sendRouteError } from '../utils/http.js' + +const router = Router() + +router.get('/:code', async (req, res) => { + const requestId = createRequestId('sl') + const startedAt = Date.now() + + try { + const row = await resolveShortLinkTarget(req.params.code) + logIntegration('[short-link]', '短链跳转', { + requestId, + code: row.code, + taskId: row.task_id || null, + source: row.source, + durationMs: Date.now() - startedAt, + }) + res.redirect(302, row.target_url) + } catch (error) { + logIntegration('[short-link]', '短链跳转失败', { + requestId, + code: req.params.code, + durationMs: Date.now() - startedAt, + error, + }, { level: 'warn' }) + sendRouteError(res, error, '短链不可用', '[short-link]') + } +}) + +export default router diff --git a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts index d9469ef8..759619a7 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts @@ -25,3 +25,19 @@ test('resolveKuaishouFeifeiClaimUrl does not expose local claim token fallback', '', ) }) + +test('resolveKuaishouFeifeiClaimUrl prefers internal short link', () => { + assert.equal( + resolveKuaishouFeifeiClaimUrl({ + shortLink: { + code: 'AbCd1234', + url: 'https://ks.khhao.com/s/AbCd1234', + targetUrl: 'https://feifei.example.com/h5/bind?code=very-long', + }, + h5: { + rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long', + }, + }), + 'https://ks.khhao.com/s/AbCd1234', + ) +}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts index 469e1ab7..54e3f1d7 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts @@ -11,6 +11,11 @@ import { } from '../../platforms/kuaishou-feifei/order-service.js' import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js' import { buildKuaishouIndustryVoucherContext } from '../../platforms/kuaishou-industry/voucher-binding-service.js' +import { + SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM, + ensureShortLinkForTarget, + normalizeShortLinkPayload, +} from '../../short-links/short-link-service.js' import type { TaskRow } from '../../../types/repository/rows.js' type JsonObject = Record @@ -33,12 +38,10 @@ export async function prepareKuaishouFeifeiTask(task: TaskRow) { const existingClaimUrl = resolveKuaishouFeifeiClaimUrl(flow) if (flow.orderNo && existingClaimUrl) { + const nextFlow = await ensureKuaishouFeifeiFlowShortLink(flow, task) const nextContext = { ...taskContext, - kuaishouFeifei: { - ...flow, - claimUrl: existingClaimUrl, - }, + kuaishouFeifei: nextFlow, } return updateTask(task.id, { task_status: TASK_STATUS.LINK_GENERATED, @@ -69,11 +72,11 @@ export async function prepareKuaishouFeifeiTask(task: TaskRow) { }) } - const nextFlow = mergeKuaishouFeifeiOrder(flow, order, { + const nextFlow = await ensureKuaishouFeifeiFlowShortLink(mergeKuaishouFeifeiOrder(flow, order, { platformOrderNo, claimUrl: feifeiClaimUrl, syncedAt: now, - }) + }), task) const updatedTask = await updateTask(task.id, { task_status: TASK_STATUS.LINK_GENERATED, user_action_status: 'pending_claim', @@ -125,10 +128,10 @@ export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) { let resultCode = task.result_code let resultMessage = task.result_message let lastError = task.last_error - const nextFlow = mergeKuaishouFeifeiOrder(flow, order, { + const nextFlow = await ensureKuaishouFeifeiFlowShortLink(mergeKuaishouFeifeiOrder(flow, order, { syncedAt: now, claimUrl: resolveKuaishouFeifeiOrderClaimUrl(order) || resolveKuaishouFeifeiClaimUrl(flow), - }) + }), task) let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher if (order.rechargeStatus === 30) { @@ -219,6 +222,7 @@ export function normalizeKuaishouFeifeiFlow(value: unknown) { rechargeResultMessage: String(source.rechargeResultMessage || '').trim(), claimUrl: String(source.claimUrl || '').trim(), consumeStatus: String(source.consumeStatus || 'pending').trim(), + shortLink: normalizeShortLinkPayload(source.shortLink), h5: { entryUrl: String(h5.entryUrl || '').trim(), rechargeUrl: String(h5.rechargeUrl || '').trim(), @@ -230,12 +234,36 @@ export function normalizeKuaishouFeifeiFlow(value: unknown) { export function resolveKuaishouFeifeiClaimUrl(value: unknown) { const flow = normalizeKuaishouFeifeiFlow(value) - const h5ClaimUrl = flow.h5.rechargeUrl || flow.h5.entryUrl - if (h5ClaimUrl) { - return h5ClaimUrl + if (flow.shortLink?.url) { + return flow.shortLink.url } - return isLocalClaimUrl(flow.claimUrl) ? '' : flow.claimUrl + return resolveKuaishouFeifeiDirectClaimUrl(flow) +} + +export async function ensureKuaishouFeifeiClaimShortLink(task: TaskRow) { + if (!isKuaishouFeifeiTask(task)) { + return '' + } + + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei) + const nextFlow = await ensureKuaishouFeifeiFlowShortLink(flow, task) + const claimUrl = resolveKuaishouFeifeiClaimUrl(nextFlow) + + if (JSON.stringify(flow.shortLink || null) === JSON.stringify(nextFlow.shortLink || null)) { + return claimUrl + } + + await updateTask(task.id, { + context_json: JSON.stringify({ + ...taskContext, + kuaishouFeifei: nextFlow, + }), + updated_at: nowIso(), + }) + + return claimUrl } function mergeKuaishouFeifeiOrder( @@ -257,6 +285,7 @@ function mergeKuaishouFeifeiOrder( rechargeStatusLabel: order.rechargeStatusLabel, rechargeResultMessage: order.rechargeResultMessage, claimUrl: patch.claimUrl || flow.claimUrl, + shortLink: flow.shortLink, h5: { entryUrl: order.h5.entryUrl || flow.h5.entryUrl, rechargeUrl: order.h5.rechargeUrl || flow.h5.rechargeUrl, @@ -266,6 +295,45 @@ function mergeKuaishouFeifeiOrder( } } +async function ensureKuaishouFeifeiFlowShortLink( + flow: ReturnType, + task: TaskRow, +) { + const targetUrl = resolveKuaishouFeifeiDirectClaimUrl(flow) + if (!targetUrl) { + return flow + } + + if (flow.shortLink?.url && flow.shortLink.targetUrl === targetUrl) { + return flow + } + + const shortLink = await ensureShortLinkForTarget(targetUrl, { + source: SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM, + taskId: task.id, + }) + + return { + ...flow, + shortLink, + } +} + +function resolveKuaishouFeifeiDirectClaimUrl( + flow: ReturnType, +) { + const h5ClaimUrl = flow.h5.rechargeUrl || flow.h5.entryUrl + if (h5ClaimUrl) { + return h5ClaimUrl + } + + if (flow.shortLink?.targetUrl) { + return flow.shortLink.targetUrl + } + + return isLocalClaimUrl(flow.claimUrl) ? '' : flow.claimUrl +} + function resolveKuaishouFeifeiOrderClaimUrl(order: Awaited>) { return String(order.h5.rechargeUrl || order.h5.entryUrl || '').trim() } diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index 23ffad58..0d386412 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -2,7 +2,10 @@ import { findLatestOrderByPlatformOrderId } from '../../repositories/order-repo. import { listTasksByOrderId } from '../../repositories/task-repo.js' import { buildClaimUrl } from '../claim/claim-service.js' import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud/index.js' -import { resolveKuaishouFeifeiClaimUrl } from '../fulfillment/kuaishou-feifei/index.js' +import { + ensureKuaishouFeifeiClaimShortLink, + resolveKuaishouFeifeiClaimUrl, +} from '../fulfillment/kuaishou-feifei/index.js' import { logIntegration } from '../../utils/logger.js' import { OPEN_91_MANUAL_FAILED_STATUS, @@ -91,7 +94,10 @@ export async function queryOpen91Order(payload: JsonObject = {}, { requestId = ' if (String(task.executor_key || '').trim() === 'kuaishou_feifei') { const context = parseJsonObject(task.context_json) - claimUrl = resolveKuaishouFeifeiClaimUrl(context.kuaishouFeifei) + claimUrl = await ensureKuaishouFeifeiClaimShortLink(task) + if (!claimUrl) { + claimUrl = resolveKuaishouFeifeiClaimUrl(context.kuaishouFeifei) + } expireTime = '' } else if (primaryToken) { claimUrl = buildClaimUrl(primaryToken) diff --git a/apps/backend/src/services/short-links/short-link-service.ts b/apps/backend/src/services/short-links/short-link-service.ts new file mode 100644 index 00000000..6b4c5f95 --- /dev/null +++ b/apps/backend/src/services/short-links/short-link-service.ts @@ -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 { + 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 { + 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 + 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') +} diff --git a/apps/backend/src/types/repository/inputs.ts b/apps/backend/src/types/repository/inputs.ts index 2cc29e95..0398ec8e 100644 --- a/apps/backend/src/types/repository/inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -149,3 +149,14 @@ export type KuaishouIndustryVoucherUpsertInput = { export type KuaishouIndustryVoucherUpdatePatch = Partial< Omit > + +export type ShortLinkCreateInput = { + code: string + targetUrl: string + targetUrlHash: string + source?: string + taskId?: number | string | null + expiresAt?: string | null + createdAt: string + updatedAt: string +} diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index ad727c9c..706d872c 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -124,6 +124,20 @@ export type KuaishouIndustryVoucherRow = { updated_at: string } +export type ShortLinkRow = { + id: number + code: string + target_url: string + target_url_hash: string + source: string + task_id: number | null + expires_at: string | null + visit_count: number + last_visited_at: string | null + created_at: string + updated_at: string +} + export type OrderListQueryResult = { items: OrderListRow[] total: number diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 2156da7e..46d3295f 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -8,6 +8,10 @@ reverse_proxy backend:3000 } + handle /s/* { + reverse_proxy backend:3000 + } + encode zstd gzip root * /srv try_files {path} /index.html diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev index a6508560..5bc55e7c 100644 --- a/deploy/caddy/Caddyfile.dev +++ b/deploy/caddy/Caddyfile.dev @@ -8,6 +8,10 @@ reverse_proxy backend:3000 } + handle /s/* { + reverse_proxy backend:3000 + } + handle { reverse_proxy frontend:5173 }