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:
yml2213
2026-10-09 12:01:35 +08:00
parent 080480f03b
commit 8c33ff62ec
7 changed files with 410 additions and 0 deletions
+21
View File
@@ -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>
)
}
+16
View File
@@ -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 })
}
+19
View File
@@ -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) {
+39
View File
@@ -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
}
+82
View File
@@ -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: '订单状态未知' }
}
}
+67
View File
@@ -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}$/)
})