增加飞飞领取短链
This commit is contained in:
@@ -7,6 +7,7 @@ import claimsRouter from "./routes/claims.js";
|
|||||||
import kuaishouFeifeiRouter from "./routes/kuaishou-feifei.js";
|
import kuaishouFeifeiRouter from "./routes/kuaishou-feifei.js";
|
||||||
import kuaishouIndustryRouter from "./routes/kuaishou-industry.js";
|
import kuaishouIndustryRouter from "./routes/kuaishou-industry.js";
|
||||||
import open91Router from "./routes/open-91.js";
|
import open91Router from "./routes/open-91.js";
|
||||||
|
import shortLinksRouter from "./routes/short-links.js";
|
||||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||||
import { createCorsMiddleware } from "./middleware/cors.js";
|
import { createCorsMiddleware } from "./middleware/cors.js";
|
||||||
import { buildHealthPayload, type StartupState } from "./startup/state.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/open/kuaishou-feifei", kuaishouFeifeiRouter);
|
||||||
app.use("/api/v1/claim", claimsRouter);
|
app.use("/api/v1/claim", claimsRouter);
|
||||||
app.use("/api/v1/admin", adminRouter);
|
app.use("/api/v1/admin", adminRouter);
|
||||||
|
app.use("/s", shortLinksRouter);
|
||||||
|
|
||||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
sendRouteError(res, err, "服务内部错误", "[global]");
|
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
|
||||||
|
}
|
||||||
@@ -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'
|
} from '../../platforms/kuaishou-feifei/order-service.js'
|
||||||
import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js'
|
import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js'
|
||||||
import { buildKuaishouIndustryVoucherContext } from '../../platforms/kuaishou-industry/voucher-binding-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'
|
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
@@ -33,12 +38,10 @@ export async function prepareKuaishouFeifeiTask(task: TaskRow) {
|
|||||||
|
|
||||||
const existingClaimUrl = resolveKuaishouFeifeiClaimUrl(flow)
|
const existingClaimUrl = resolveKuaishouFeifeiClaimUrl(flow)
|
||||||
if (flow.orderNo && existingClaimUrl) {
|
if (flow.orderNo && existingClaimUrl) {
|
||||||
|
const nextFlow = await ensureKuaishouFeifeiFlowShortLink(flow, task)
|
||||||
const nextContext = {
|
const nextContext = {
|
||||||
...taskContext,
|
...taskContext,
|
||||||
kuaishouFeifei: {
|
kuaishouFeifei: nextFlow,
|
||||||
...flow,
|
|
||||||
claimUrl: existingClaimUrl,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return updateTask(task.id, {
|
return updateTask(task.id, {
|
||||||
task_status: TASK_STATUS.LINK_GENERATED,
|
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,
|
platformOrderNo,
|
||||||
claimUrl: feifeiClaimUrl,
|
claimUrl: feifeiClaimUrl,
|
||||||
syncedAt: now,
|
syncedAt: now,
|
||||||
})
|
}), task)
|
||||||
const updatedTask = await updateTask(task.id, {
|
const updatedTask = await updateTask(task.id, {
|
||||||
task_status: TASK_STATUS.LINK_GENERATED,
|
task_status: TASK_STATUS.LINK_GENERATED,
|
||||||
user_action_status: 'pending_claim',
|
user_action_status: 'pending_claim',
|
||||||
@@ -125,10 +128,10 @@ export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) {
|
|||||||
let resultCode = task.result_code
|
let resultCode = task.result_code
|
||||||
let resultMessage = task.result_message
|
let resultMessage = task.result_message
|
||||||
let lastError = task.last_error
|
let lastError = task.last_error
|
||||||
const nextFlow = mergeKuaishouFeifeiOrder(flow, order, {
|
const nextFlow = await ensureKuaishouFeifeiFlowShortLink(mergeKuaishouFeifeiOrder(flow, order, {
|
||||||
syncedAt: now,
|
syncedAt: now,
|
||||||
claimUrl: resolveKuaishouFeifeiOrderClaimUrl(order) || resolveKuaishouFeifeiClaimUrl(flow),
|
claimUrl: resolveKuaishouFeifeiOrderClaimUrl(order) || resolveKuaishouFeifeiClaimUrl(flow),
|
||||||
})
|
}), task)
|
||||||
let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher
|
let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher
|
||||||
|
|
||||||
if (order.rechargeStatus === 30) {
|
if (order.rechargeStatus === 30) {
|
||||||
@@ -219,6 +222,7 @@ export function normalizeKuaishouFeifeiFlow(value: unknown) {
|
|||||||
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
|
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
|
||||||
claimUrl: String(source.claimUrl || '').trim(),
|
claimUrl: String(source.claimUrl || '').trim(),
|
||||||
consumeStatus: String(source.consumeStatus || 'pending').trim(),
|
consumeStatus: String(source.consumeStatus || 'pending').trim(),
|
||||||
|
shortLink: normalizeShortLinkPayload(source.shortLink),
|
||||||
h5: {
|
h5: {
|
||||||
entryUrl: String(h5.entryUrl || '').trim(),
|
entryUrl: String(h5.entryUrl || '').trim(),
|
||||||
rechargeUrl: String(h5.rechargeUrl || '').trim(),
|
rechargeUrl: String(h5.rechargeUrl || '').trim(),
|
||||||
@@ -230,12 +234,36 @@ export function normalizeKuaishouFeifeiFlow(value: unknown) {
|
|||||||
|
|
||||||
export function resolveKuaishouFeifeiClaimUrl(value: unknown) {
|
export function resolveKuaishouFeifeiClaimUrl(value: unknown) {
|
||||||
const flow = normalizeKuaishouFeifeiFlow(value)
|
const flow = normalizeKuaishouFeifeiFlow(value)
|
||||||
const h5ClaimUrl = flow.h5.rechargeUrl || flow.h5.entryUrl
|
if (flow.shortLink?.url) {
|
||||||
if (h5ClaimUrl) {
|
return flow.shortLink.url
|
||||||
return h5ClaimUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
function mergeKuaishouFeifeiOrder(
|
||||||
@@ -257,6 +285,7 @@ function mergeKuaishouFeifeiOrder(
|
|||||||
rechargeStatusLabel: order.rechargeStatusLabel,
|
rechargeStatusLabel: order.rechargeStatusLabel,
|
||||||
rechargeResultMessage: order.rechargeResultMessage,
|
rechargeResultMessage: order.rechargeResultMessage,
|
||||||
claimUrl: patch.claimUrl || flow.claimUrl,
|
claimUrl: patch.claimUrl || flow.claimUrl,
|
||||||
|
shortLink: flow.shortLink,
|
||||||
h5: {
|
h5: {
|
||||||
entryUrl: order.h5.entryUrl || flow.h5.entryUrl,
|
entryUrl: order.h5.entryUrl || flow.h5.entryUrl,
|
||||||
rechargeUrl: order.h5.rechargeUrl || flow.h5.rechargeUrl,
|
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>>) {
|
function resolveKuaishouFeifeiOrderClaimUrl(order: Awaited<ReturnType<typeof createKuaishouFeifeiOrder>>) {
|
||||||
return String(order.h5.rechargeUrl || order.h5.entryUrl || '').trim()
|
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 { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||||
import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud/index.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 { logIntegration } from '../../utils/logger.js'
|
||||||
import {
|
import {
|
||||||
OPEN_91_MANUAL_FAILED_STATUS,
|
OPEN_91_MANUAL_FAILED_STATUS,
|
||||||
@@ -91,7 +94,10 @@ export async function queryOpen91Order(payload: JsonObject = {}, { requestId = '
|
|||||||
|
|
||||||
if (String(task.executor_key || '').trim() === 'kuaishou_feifei') {
|
if (String(task.executor_key || '').trim() === 'kuaishou_feifei') {
|
||||||
const context = parseJsonObject(task.context_json)
|
const context = parseJsonObject(task.context_json)
|
||||||
|
claimUrl = await ensureKuaishouFeifeiClaimShortLink(task)
|
||||||
|
if (!claimUrl) {
|
||||||
claimUrl = resolveKuaishouFeifeiClaimUrl(context.kuaishouFeifei)
|
claimUrl = resolveKuaishouFeifeiClaimUrl(context.kuaishouFeifei)
|
||||||
|
}
|
||||||
expireTime = ''
|
expireTime = ''
|
||||||
} else if (primaryToken) {
|
} else if (primaryToken) {
|
||||||
claimUrl = buildClaimUrl(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<
|
export type KuaishouIndustryVoucherUpdatePatch = Partial<
|
||||||
Omit<KuaishouIndustryVoucherUpsertInput, 'oid' | 'unitIndex' | 'createdAt'>
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,6 +124,20 @@ export type KuaishouIndustryVoucherRow = {
|
|||||||
updated_at: string
|
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 = {
|
export type OrderListQueryResult = {
|
||||||
items: OrderListRow[]
|
items: OrderListRow[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
reverse_proxy backend:3000
|
reverse_proxy backend:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle /s/* {
|
||||||
|
reverse_proxy backend:3000
|
||||||
|
}
|
||||||
|
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
root * /srv
|
root * /srv
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
reverse_proxy backend:3000
|
reverse_proxy backend:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle /s/* {
|
||||||
|
reverse_proxy backend:3000
|
||||||
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy frontend:5173
|
reverse_proxy frontend:5173
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user