增加飞飞领取短链

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
+2
View File
@@ -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]");
@@ -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);
@@ -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<ShortLinkRow | null> {
const result = await query<ShortLinkRow>(
`
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<ShortLinkRow | null> {
const normalizedCode = String(code || '').trim()
if (!normalizedCode) {
return null
}
const result = await query<ShortLinkRow>(
`
SELECT *
FROM short_links
WHERE code = $1
LIMIT 1
`,
[normalizedCode],
)
return result.rows[0] || null
}
export async function markShortLinkVisited(
code: string,
visitedAt: string,
): Promise<ShortLinkRow | null> {
const normalizedCode = String(code || '').trim()
if (!normalizedCode) {
return null
}
const result = await query<ShortLinkRow>(
`
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
}
+34
View File
@@ -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
@@ -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',
)
})
@@ -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<string, any>
@@ -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<typeof normalizeKuaishouFeifeiFlow>,
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<typeof normalizeKuaishouFeifeiFlow>,
) {
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<ReturnType<typeof createKuaishouFeifeiOrder>>) {
return String(order.h5.rechargeUrl || order.h5.entryUrl || '').trim()
}
@@ -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)
@@ -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')
}
@@ -149,3 +149,14 @@ export type KuaishouIndustryVoucherUpsertInput = {
export type KuaishouIndustryVoucherUpdatePatch = Partial<
Omit<KuaishouIndustryVoucherUpsertInput, 'oid' | 'unitIndex' | 'createdAt'>
>
export type ShortLinkCreateInput = {
code: string
targetUrl: string
targetUrlHash: string
source?: string
taskId?: number | string | null
expiresAt?: string | null
createdAt: string
updatedAt: string
}
+14
View File
@@ -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