增加飞飞领取短链
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
reverse_proxy backend:3000
|
||||
}
|
||||
|
||||
handle /s/* {
|
||||
reverse_proxy backend:3000
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
root * /srv
|
||||
try_files {path} /index.html
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
reverse_proxy backend:3000
|
||||
}
|
||||
|
||||
handle /s/* {
|
||||
reverse_proxy backend:3000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:5173
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user