feat(redeem): 固定兑换链接 /redeem(输入订单号 → 状态分流 → 兑换)
- 路径式入口 `https://<域名>/redeem`(不带 #):主站是 HashRouter, 故在 App.tsx 里早于 isWorkerSite() 插入独立 BrowserRouter 分支, 主站与打手站路由均不受影响;Caddy 已有 try_files 兜底,无需改部署 - 页面按 7 种状态渲染:not_found / unpaid / cancelled / refunded / pending_certificate / redeemable / verified - not_found 保留用户输入的订单号,不清空 - pending_certificate 只给显式「刷新」,不做自动轮询(公开限流接口,避免空转) - verified 只展示后端已掩码的券码,绝不渲染明文 certNo - confirm 不假设成功:一律按返回的 status 重渲染 - 纯函数抽到 utils/redeem.ts(无 React/antd、无 @ 别名),可直接跑单测
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
} from 'react-router'
|
||||
|
||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||
import { isRedeemPath } from '@/utils/redeem'
|
||||
import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site'
|
||||
import { hasWorkerSession } from '@/utils/worker-auth'
|
||||
import { resolveLegacyWorkerPath } from '@/utils/worker-routes'
|
||||
@@ -36,6 +37,7 @@ const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlat
|
||||
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
|
||||
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
|
||||
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
|
||||
const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage'))
|
||||
const WorkerOrdersPage = lazy(() => import('@/pages/worker/WorkerOrdersPage'))
|
||||
@@ -88,6 +90,12 @@ export default function App() {
|
||||
return <WorkerSiteRedirect path={legacyWorkerPath} />
|
||||
}
|
||||
|
||||
// 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断,
|
||||
// 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。
|
||||
if (isRedeemPath(window.location.pathname)) {
|
||||
return <RedeemApplication />
|
||||
}
|
||||
|
||||
if (isWorkerSite()) {
|
||||
return <WorkerApplication />
|
||||
}
|
||||
@@ -137,6 +145,19 @@ function AdminApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
function RedeemApplication() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/redeem" element={<RedeemPage />} />
|
||||
<Route path="*" element={<Navigate to="/redeem" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkerApplication() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, Result, Space, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { confirmRedeemOrder, lookupRedeemOrder } from '@/services/redeem'
|
||||
import type { RedeemOrderStatus, RedeemOrderView } from '@/types/redeem'
|
||||
import { formatRedeemAmount, formatRedeemTime, resolveRedeemStatusView } from '@/utils/redeem'
|
||||
|
||||
/**
|
||||
* 固定兑换链接页面(`https://<域名>/redeem`)。
|
||||
*
|
||||
* 只用 `useState` 驱动、不引 TanStack Query:这是一次性公开链接,
|
||||
* 查询与确认都由用户显式触发,没有需要缓存/复用的服务端状态。
|
||||
* 流程:输订单号 → lookup → 按 status 渲染 → 可兑换时 confirm。
|
||||
*/
|
||||
export default function RedeemPage() {
|
||||
const [orderNo, setOrderNo] = useState('')
|
||||
const [view, setView] = useState<RedeemOrderView | null>(null)
|
||||
const [lookupLoading, setLookupLoading] = useState(false)
|
||||
const [confirmLoading, setConfirmLoading] = useState(false)
|
||||
|
||||
async function submitLookup(values: { orderNo: string }) {
|
||||
const nextOrderNo = String(values.orderNo || '').trim()
|
||||
setOrderNo(nextOrderNo)
|
||||
await runLookup(nextOrderNo)
|
||||
}
|
||||
|
||||
async function runLookup(currentOrderNo: string) {
|
||||
setLookupLoading(true)
|
||||
try {
|
||||
const response = await lookupRedeemOrder(currentOrderNo)
|
||||
setView(response.data)
|
||||
} catch (error) {
|
||||
setView(null)
|
||||
showError(error instanceof Error ? error.message : '查询失败')
|
||||
} finally {
|
||||
setLookupLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function refreshLookup() {
|
||||
if (!orderNo) return
|
||||
void runLookup(orderNo)
|
||||
}
|
||||
|
||||
async function submitConfirm() {
|
||||
if (!orderNo) return
|
||||
setConfirmLoading(true)
|
||||
try {
|
||||
const response = await confirmRedeemOrder(orderNo)
|
||||
// confirm 在不可兑换时不抛错,会把当前状态原样返回:一律按 status 重新渲染。
|
||||
setView(response.data)
|
||||
|
||||
if (response.data.status === 'verified') {
|
||||
showSuccess(response.data.message || '兑换成功')
|
||||
} else {
|
||||
showError(response.data.message || '当前订单暂不可兑换')
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '兑换失败')
|
||||
} finally {
|
||||
setConfirmLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="redeem-page">
|
||||
<Card className="redeem-card" bordered={false}>
|
||||
<Typography.Title level={3}>兑换中心</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
请输入订单号,核对订单状态后按提示完成兑换。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form layout="vertical" onFinish={submitLookup}>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
className="redeem-order-input"
|
||||
rules={[{ required: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input placeholder="订单号" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SearchOutlined />}
|
||||
loading={lookupLoading}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form>
|
||||
|
||||
{view?.status === 'not_found' ? (
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type="warning"
|
||||
showIcon
|
||||
message={view.message || '订单号有误,或订单尚未同步,请稍后再试'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view &&
|
||||
(view.status === 'unpaid' || view.status === 'cancelled' || view.status === 'refunded') ? (
|
||||
<RedeemStatusResult status={view.status} message={view.message} />
|
||||
) : null}
|
||||
|
||||
{view?.status === 'pending_certificate' ? (
|
||||
<>
|
||||
<RedeemStatusResult status={view.status} message={view.message} />
|
||||
{/* 券在后台异步准备:不做自动轮询,由用户点「刷新」按需重查,避免空转请求。 */}
|
||||
<Button block onClick={refreshLookup} loading={lookupLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{view?.status === 'redeemable' ? (
|
||||
<div className="platform-section-gap">
|
||||
<RedeemSummaryDetails summary={view.summary} />
|
||||
<Button type="primary" block loading={confirmLoading} onClick={submitConfirm}>
|
||||
确认兑换
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view?.status === 'verified' ? (
|
||||
<div className="platform-section-gap">
|
||||
<RedeemStatusResult status={view.status} message={view.message} />
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
{/* 只展示后端返回的掩码券码,绝不明文铺开 certNo。 */}
|
||||
<Descriptions.Item label="券码">
|
||||
{view.credential?.maskedCertNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="兑换时间">
|
||||
{formatRedeemTime(view.credential?.verifiedAt) || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function RedeemStatusResult({ status, message }: { status: RedeemOrderStatus; message: string }) {
|
||||
const { tone, title } = resolveRedeemStatusView(status)
|
||||
|
||||
return <Result status={tone} title={title} subTitle={message || undefined} />
|
||||
}
|
||||
|
||||
function RedeemSummaryDetails({ summary }: { summary: RedeemOrderView['summary'] }) {
|
||||
return (
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="商品">{summary?.productName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="数量">{summary ? summary.quantity : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{summary ? formatRedeemAmount(summary.amountFen) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付时间">
|
||||
{formatRedeemTime(summary?.paidAt) || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { apiPost } from '@/lib/http'
|
||||
import type { RedeemOrderView } from '@/types/redeem'
|
||||
|
||||
/**
|
||||
* 固定兑换链接的公开接口封装。
|
||||
*
|
||||
* 两者都返回同一个 `RedeemOrderView`:`confirm` 在不可兑换时**不抛错**,
|
||||
* 会把当前状态原样返回,所以调用方必须按 `status` 渲染,不能假设一定成功。
|
||||
*/
|
||||
export function lookupRedeemOrder(orderNo: string) {
|
||||
return apiPost<RedeemOrderView>('/api/v1/redeem/lookup', { orderNo })
|
||||
}
|
||||
|
||||
export function confirmRedeemOrder(orderNo: string) {
|
||||
return apiPost<RedeemOrderView>('/api/v1/redeem/confirm', { orderNo })
|
||||
}
|
||||
@@ -453,6 +453,25 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ===== 兑换中心 (Redeem Page) ===== */
|
||||
|
||||
.redeem-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 16px;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.redeem-card {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.redeem-order-input {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ===== 买家页面响应式断点 ===== */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 固定兑换链接(`https://<域名>/redeem`)的接口类型。
|
||||
*
|
||||
* 与后端 `apps/backend/src/services/redeem/redeem-service.ts` 的 `RedeemOrderView` 保持一致:
|
||||
* 只包含脱敏摘要,不含买家手机号 / 地址 / open_id;券码一律走 `maskedCertNo`。
|
||||
*/
|
||||
|
||||
export type RedeemOrderStatus =
|
||||
| 'not_found'
|
||||
| 'unpaid'
|
||||
| 'cancelled'
|
||||
| 'refunded'
|
||||
| 'pending_certificate'
|
||||
| 'redeemable'
|
||||
| 'verified'
|
||||
|
||||
export interface RedeemOrderSummary {
|
||||
productName: string
|
||||
quantity: number
|
||||
amountFen: number
|
||||
paidAt: string | null
|
||||
}
|
||||
|
||||
export interface RedeemCredential {
|
||||
/** 完整券码:仅保留在类型里,页面上禁止明文展示 */
|
||||
certNo: string
|
||||
/** 掩码券码(前 6 后 4),页面展示用 */
|
||||
maskedCertNo: string
|
||||
verifiedAt: string | null
|
||||
}
|
||||
|
||||
export interface RedeemOrderView {
|
||||
status: RedeemOrderStatus
|
||||
orderNo: string
|
||||
/** 脱敏摘要(不含买家隐私字段) */
|
||||
summary: RedeemOrderSummary | null
|
||||
message: string
|
||||
credential: RedeemCredential | null
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 固定兑换链接(`/redeem`)的纯展示逻辑。
|
||||
*
|
||||
* 这个页面是「无 token 公开链接」:用户只输订单号,系统按订单状态决定下一步。
|
||||
* 把路径判定、金额格式化、状态 → 文案/色调集中在这里,页面只负责渲染。
|
||||
* 刻意写成纯函数:不依赖 React / antd,也不使用 `@/` 别名,
|
||||
* 方便 `node --experimental-strip-types --test` 直接跑单测。
|
||||
*/
|
||||
import type { RedeemOrderStatus } from '../types/redeem'
|
||||
|
||||
export type { RedeemOrderStatus }
|
||||
|
||||
/** 状态色调:与 antd Result / Alert 的语义色一致 */
|
||||
export type RedeemStatusTone = 'success' | 'warning' | 'error' | 'info'
|
||||
|
||||
export type RedeemStatusView = {
|
||||
tone: RedeemStatusTone
|
||||
/** 页面主标题(中性文案,不出现平台名) */
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定兑换链接是**路径式**(`https://<域名>/redeem`,不带 `#`),
|
||||
* 判断时只认 `/redeem`(容忍一个或多个结尾斜杠),`/redeemx`、`/` 都不算。
|
||||
*/
|
||||
export function isRedeemPath(pathname: unknown): boolean {
|
||||
const normalized = String(pathname ?? '').trim()
|
||||
if (!normalized) return false
|
||||
|
||||
return normalized.replace(/\/+$/, '') === '/redeem'
|
||||
}
|
||||
|
||||
/**
|
||||
* 分 → 元展示(与后台 `formatMoney` 同款格式)。
|
||||
* 空值 / 非法值按 0 处理:`1990 → ¥19.90`、`0 → ¥0.00`。
|
||||
*/
|
||||
export function formatRedeemAmount(fen: unknown): string {
|
||||
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间 → `YYYY-MM-DD HH:mm`;空值或非法时间返回空串(调用方自行显示占位符)。
|
||||
*/
|
||||
export function formatRedeemTime(value: unknown): string {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (!normalized) return ''
|
||||
|
||||
const date = new Date(normalized)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const pad = (input: number) => String(input).padStart(2, '0')
|
||||
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
const timePart = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
return `${datePart} ${timePart}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单状态 → 页面标题与色调。
|
||||
*
|
||||
* 后端以后新增状态时不能崩:未知状态一律回退成中性提示。
|
||||
* 注意 `pending_certificate` 是「稍后再试」而不是「失败」,所以用 info 而不是 error。
|
||||
*/
|
||||
export function resolveRedeemStatusView(status: RedeemOrderStatus): RedeemStatusView {
|
||||
switch (status) {
|
||||
case 'not_found':
|
||||
return { tone: 'warning', title: '未找到订单' }
|
||||
case 'unpaid':
|
||||
return { tone: 'warning', title: '订单未支付' }
|
||||
case 'cancelled':
|
||||
return { tone: 'error', title: '订单已取消' }
|
||||
case 'refunded':
|
||||
return { tone: 'error', title: '订单已退款' }
|
||||
case 'pending_certificate':
|
||||
return { tone: 'info', title: '兑换券准备中' }
|
||||
case 'redeemable':
|
||||
return { tone: 'success', title: '可以兑换' }
|
||||
case 'verified':
|
||||
return { tone: 'success', title: '已兑换' }
|
||||
default:
|
||||
return { tone: 'info', title: '订单状态未知' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatRedeemAmount,
|
||||
formatRedeemTime,
|
||||
isRedeemPath,
|
||||
resolveRedeemStatusView,
|
||||
} from '../src/utils/redeem.ts'
|
||||
import type { RedeemOrderStatus } from '../src/types/redeem.ts'
|
||||
|
||||
/** 故意构造后端未来可能新增的状态,验证未知状态不会崩 */
|
||||
const FUTURE_STATUS = 'reviewing' as unknown as RedeemOrderStatus
|
||||
|
||||
test('isRedeemPath:只认路径式 /redeem,容忍结尾斜杠', () => {
|
||||
assert.equal(isRedeemPath('/redeem'), true)
|
||||
assert.equal(isRedeemPath('/redeem/'), true)
|
||||
assert.equal(isRedeemPath('/redeem//'), true)
|
||||
})
|
||||
|
||||
test('isRedeemPath:/redeemx、/、空串都不算兑换路径', () => {
|
||||
assert.equal(isRedeemPath('/redeemx'), false)
|
||||
assert.equal(isRedeemPath('/'), false)
|
||||
assert.equal(isRedeemPath('/admin/redeem'), false)
|
||||
assert.equal(isRedeemPath(''), false)
|
||||
assert.equal(isRedeemPath(null), false)
|
||||
})
|
||||
|
||||
test('formatRedeemAmount:分转元保留两位小数', () => {
|
||||
assert.equal(formatRedeemAmount(1990), '¥19.90')
|
||||
assert.equal(formatRedeemAmount(0), '¥0.00')
|
||||
assert.equal(formatRedeemAmount(5), '¥0.05')
|
||||
})
|
||||
|
||||
test('resolveRedeemStatusView:7 个状态各有标题与色调', () => {
|
||||
assert.deepEqual(resolveRedeemStatusView('not_found'), {
|
||||
tone: 'warning',
|
||||
title: '未找到订单',
|
||||
})
|
||||
assert.deepEqual(resolveRedeemStatusView('unpaid'), { tone: 'warning', title: '订单未支付' })
|
||||
assert.deepEqual(resolveRedeemStatusView('cancelled'), { tone: 'error', title: '订单已取消' })
|
||||
assert.deepEqual(resolveRedeemStatusView('refunded'), { tone: 'error', title: '订单已退款' })
|
||||
assert.deepEqual(resolveRedeemStatusView('pending_certificate'), {
|
||||
tone: 'info',
|
||||
title: '兑换券准备中',
|
||||
})
|
||||
assert.deepEqual(resolveRedeemStatusView('redeemable'), {
|
||||
tone: 'success',
|
||||
title: '可以兑换',
|
||||
})
|
||||
assert.deepEqual(resolveRedeemStatusView('verified'), { tone: 'success', title: '已兑换' })
|
||||
})
|
||||
|
||||
test('resolveRedeemStatusView:未知状态回退为中性提示', () => {
|
||||
assert.deepEqual(resolveRedeemStatusView(FUTURE_STATUS), {
|
||||
tone: 'info',
|
||||
title: '订单状态未知',
|
||||
})
|
||||
})
|
||||
|
||||
test('formatRedeemTime:空值或非法时间返回空串,合法时间格式化为 YYYY-MM-DD HH:mm', () => {
|
||||
assert.equal(formatRedeemTime(''), '')
|
||||
assert.equal(formatRedeemTime(null), '')
|
||||
assert.equal(formatRedeemTime(' '), '')
|
||||
assert.equal(formatRedeemTime('不是时间'), '')
|
||||
assert.match(formatRedeemTime('2026-09-17T10:00:00'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||||
})
|
||||
Reference in New Issue
Block a user