支持领取链接长期有效
This commit is contained in:
@@ -15,6 +15,7 @@ BACKEND_PORT=3000
|
|||||||
|
|
||||||
# Claim Links
|
# Claim Links
|
||||||
CLAIM_BASE_URL=https://order.khhao.com/#/claim
|
CLAIM_BASE_URL=https://order.khhao.com/#/claim
|
||||||
|
CLAIM_TOKEN_TTL_HOURS=0
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
POSTGRES_DB=order_site
|
POSTGRES_DB=order_site
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ cp .env.server.example .env
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `APP_DOMAIN` / `CADDY_SITE_ADDR` | 对外域名与 Caddy 监听地址 |
|
| `APP_DOMAIN` / `CADDY_SITE_ADDR` | 对外域名与 Caddy 监听地址 |
|
||||||
| `CLAIM_BASE_URL` | 领取页完整前缀,如 `https://域名/#/claim` |
|
| `CLAIM_BASE_URL` | 领取页完整前缀,如 `https://域名/#/claim` |
|
||||||
|
| `CLAIM_TOKEN_TTL_HOURS` | 领取链接有效期(小时);`0` 表示长期有效,正整数表示限时 |
|
||||||
| `DATABASE_URL` | PostgreSQL 连接串(容器内主机名用 `postgres`) |
|
| `DATABASE_URL` | PostgreSQL 连接串(容器内主机名用 `postgres`) |
|
||||||
| `ADMIN_SESSION_SECRET` | 后台登录态签名密钥 |
|
| `ADMIN_SESSION_SECRET` | 后台登录态签名密钥 |
|
||||||
| `ADMIN_DEFAULT_USERS_JSON` | 默认后台用户 |
|
| `ADMIN_DEFAULT_USERS_JSON` | 默认后台用户 |
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
|
|
||||||
orders: {
|
orders: {
|
||||||
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
|
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
|
||||||
tokenTtlHours: 24,
|
// 0 表示长期有效;后台手动关闭任务后领取链接仍会立即失效。
|
||||||
|
tokenTtlHours: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
admin: {
|
admin: {
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ test('applyEnvOverrides derives claim base url from app base url when explicit c
|
|||||||
assert.equal(config.orders.claimBaseUrl, 'https://example.test/#/claim')
|
assert.equal(config.orders.claimBaseUrl, 'https://example.test/#/claim')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('CLAIM_TOKEN_TTL_HOURS=0 表示领取链接长期有效', () => {
|
||||||
|
const config = applyEnvOverrides(createRuntimeConfig(), {
|
||||||
|
CLAIM_TOKEN_TTL_HOURS: '0',
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(config.orders.tokenTtlHours, 0)
|
||||||
|
})
|
||||||
|
|
||||||
test('applyEnvOverrides prefers explicit claim base url over app base url', () => {
|
test('applyEnvOverrides prefers explicit claim base url over app base url', () => {
|
||||||
const config = applyEnvOverrides(createRuntimeConfig(), {
|
const config = applyEnvOverrides(createRuntimeConfig(), {
|
||||||
APP_BASE_URL: 'https://example.test',
|
APP_BASE_URL: 'https://example.test',
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ test('validateRuntimeConfig catches invalid numeric and url values', () => {
|
|||||||
},
|
},
|
||||||
orders: {
|
orders: {
|
||||||
claimBaseUrl: 'not-a-url',
|
claimBaseUrl: 'not-a-url',
|
||||||
tokenTtlHours: 0,
|
tokenTtlHours: -1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -102,6 +102,19 @@ test('validateRuntimeConfig catches invalid numeric and url values', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('validateRuntimeConfig accepts zero claim token TTL for permanent links', () => {
|
||||||
|
const issues = validateRuntimeConfig(
|
||||||
|
createRuntimeConfig({
|
||||||
|
orders: {
|
||||||
|
tokenTtlHours: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ env: { NODE_ENV: 'development' } },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(issues, [])
|
||||||
|
})
|
||||||
|
|
||||||
test('assertRuntimeConfigValid throws a readable validation error', () => {
|
test('assertRuntimeConfigValid throws a readable validation error', () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function validateRuntimeConfig(
|
|||||||
min: 1,
|
min: 1,
|
||||||
max: 100,
|
max: 100,
|
||||||
})
|
})
|
||||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 })
|
||||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||||
requireInteger(
|
requireInteger(
|
||||||
issues,
|
issues,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 领取链接支持长期有效:expired_at 为 NULL 表示仅能由后台手动关闭。
|
||||||
|
-- 不回填已有令牌,历史限时链接仍按其原过期时间执行。
|
||||||
|
ALTER TABLE claim_tokens
|
||||||
|
ALTER COLUMN expired_at DROP NOT NULL;
|
||||||
@@ -6,7 +6,7 @@ type ClaimTokenRow = {
|
|||||||
task_id: number
|
task_id: number
|
||||||
token: string
|
token: string
|
||||||
status: string
|
status: string
|
||||||
expired_at: string
|
expired_at: string | null
|
||||||
used_at: string | null
|
used_at: string | null
|
||||||
max_use_count: number
|
max_use_count: number
|
||||||
used_count: number
|
used_count: number
|
||||||
@@ -18,7 +18,7 @@ type ClaimTokenCreateInput = {
|
|||||||
taskId: number
|
taskId: number
|
||||||
token: string
|
token: string
|
||||||
status: string
|
status: string
|
||||||
expiredAt: string
|
expiredAt: string | null
|
||||||
usedAt?: string | null
|
usedAt?: string | null
|
||||||
maxUseCount: number
|
maxUseCount: number
|
||||||
usedCount: number
|
usedCount: number
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { resolveClaimTokenExpiration } from './claim-service.js'
|
||||||
|
|
||||||
|
test('领取链接默认可配置为长期有效', () => {
|
||||||
|
assert.equal(resolveClaimTokenExpiration('2026-08-11T08:00:00.000Z', 0), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('领取链接保留正整数小时的限时配置能力', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveClaimTokenExpiration('2026-08-11T08:00:00.000Z', 24),
|
||||||
|
'2026-08-12T08:00:00.000Z',
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -10,7 +10,7 @@ type TaskClaimToken = NonNullable<Awaited<ReturnType<typeof createClaimToken>>>
|
|||||||
|
|
||||||
export async function createTaskClaimToken(taskId: number | string): Promise<TaskClaimToken> {
|
export async function createTaskClaimToken(taskId: number | string): Promise<TaskClaimToken> {
|
||||||
const createdAt = nowIso()
|
const createdAt = nowIso()
|
||||||
const expiredAt = addHours(createdAt, Number(runtimeConfig.orders.tokenTtlHours || 24))
|
const expiredAt = resolveClaimTokenExpiration(createdAt, runtimeConfig.orders.tokenTtlHours)
|
||||||
const token = await createClaimToken({
|
const token = await createClaimToken({
|
||||||
taskId: Number(taskId),
|
taskId: Number(taskId),
|
||||||
token: randomToken(24),
|
token: randomToken(24),
|
||||||
@@ -36,6 +36,17 @@ export async function createTaskClaimToken(taskId: number | string): Promise<Tas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 0 表示长期有效;正整数保留为可选的限时领取策略。 */
|
||||||
|
export function resolveClaimTokenExpiration(
|
||||||
|
createdAt: string,
|
||||||
|
tokenTtlHours: unknown,
|
||||||
|
): string | null {
|
||||||
|
const ttlHours = Number(tokenTtlHours)
|
||||||
|
return Number.isFinite(ttlHours) && ttlHours > 0
|
||||||
|
? addHours(createdAt, ttlHours)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
export function buildClaimUrl(token: string): string {
|
export function buildClaimUrl(token: string): string {
|
||||||
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
|
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
|
||||||
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
|
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type FulfillmentPrepareDeps = {
|
|||||||
updateTask: (taskId: number | string, patch: TaskUpdatePatch) => Promise<TaskRow | null>
|
updateTask: (taskId: number | string, patch: TaskUpdatePatch) => Promise<TaskRow | null>
|
||||||
createTaskClaimToken: (taskId: number | string) => Promise<{
|
createTaskClaimToken: (taskId: number | string) => Promise<{
|
||||||
token: string
|
token: string
|
||||||
expired_at: string
|
expired_at: string | null
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}>
|
}>
|
||||||
notifyTaskAutoManualReview: (payload: {
|
notifyTaskAutoManualReview: (payload: {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export type ClaimTokenRow = {
|
|||||||
id: number
|
id: number
|
||||||
token: string
|
token: string
|
||||||
status: string
|
status: string
|
||||||
expired_at: string
|
expired_at: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,7 +391,9 @@ export default function AdminTaskDetailPage() {
|
|||||||
<Typography.Text type="secondary">外部交付链接</Typography.Text>
|
<Typography.Text type="secondary">外部交付链接</Typography.Text>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
到期:{formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}
|
{resolvedDetail.claimToken.expiredAt
|
||||||
|
? `到期:${formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}`
|
||||||
|
: '长期有效'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
<Button icon={<CopyOutlined />} disabled={!claimUrl} onClick={copyClaimUrl}>
|
<Button icon={<CopyOutlined />} disabled={!claimUrl} onClick={copyClaimUrl}>
|
||||||
|
|||||||
Reference in New Issue
Block a user