feat(payment): 接入扫呗普通支付2.0
This commit is contained in:
@@ -45,6 +45,7 @@ const providerLabels: Record<string, string> = {
|
||||
leshua: '乐刷',
|
||||
lakala: '拉卡拉',
|
||||
shuncheng: '顺诚',
|
||||
saobei: '扫呗',
|
||||
mock: 'Mock',
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const providerLabels: Record<string, string> = {
|
||||
leshua: '乐刷',
|
||||
lakala: '拉卡拉',
|
||||
shuncheng: '顺诚',
|
||||
saobei: '扫呗',
|
||||
mock: 'Mock',
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ const defaultNotifyPaths: Record<string, string> = {
|
||||
leshua: '/api/payments/leshua/notify',
|
||||
lakala: '/api/payments/lakala/notify',
|
||||
shuncheng: '/api/payments/shuncheng/notify',
|
||||
saobei: '/api/payments/saobei/notify',
|
||||
mock: '',
|
||||
}
|
||||
|
||||
@@ -135,6 +137,13 @@ function defaultExtraConfig(provider: string): Record<string, unknown> {
|
||||
token_refresh_margin_seconds: 300,
|
||||
}
|
||||
}
|
||||
if (provider === 'saobei') {
|
||||
return {
|
||||
terminal_id: '',
|
||||
device_no: '',
|
||||
expire_seconds: 900,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -151,6 +160,9 @@ function extraFormDefaults(provider: string) {
|
||||
extra_shop_no: String(extra.shop_no ?? ''),
|
||||
extra_refund_type: String(extra.refund_type ?? ''),
|
||||
extra_token_refresh_margin_seconds: Number(extra.token_refresh_margin_seconds ?? 300),
|
||||
extra_terminal_id: String(extra.terminal_id ?? ''),
|
||||
extra_device_no: String(extra.device_no ?? ''),
|
||||
extra_expire_seconds: Number(extra.expire_seconds ?? 900),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +192,9 @@ interface ConfigFormValues {
|
||||
extra_shop_no?: string
|
||||
extra_refund_type?: string
|
||||
extra_token_refresh_margin_seconds?: number
|
||||
extra_terminal_id?: string
|
||||
extra_device_no?: string
|
||||
extra_expire_seconds?: number
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
@@ -196,7 +211,14 @@ export function AdminPaymentConfigsPage() {
|
||||
const isMock = providerValue === 'mock'
|
||||
const isLakala = providerValue === 'lakala'
|
||||
const isShuncheng = providerValue === 'shuncheng'
|
||||
const signKeyLabel = isLakala ? '商户私钥' : isShuncheng ? 'SecretKey' : '签名密钥'
|
||||
const isSaobei = providerValue === 'saobei'
|
||||
const signKeyLabel = isLakala
|
||||
? '商户私钥'
|
||||
: isShuncheng
|
||||
? 'SecretKey'
|
||||
: isSaobei
|
||||
? '终端密钥 access_token'
|
||||
: '签名密钥'
|
||||
const notifyKeyLabel = isLakala ? '通知证书' : '通知密钥'
|
||||
const [draft, setDraft] = useState<AdminPaymentConfigQuery>({})
|
||||
const [filters, setFilters] = useState<AdminPaymentConfigQuery>({})
|
||||
@@ -290,6 +312,9 @@ export function AdminPaymentConfigsPage() {
|
||||
extra_shop_no: String(extra.shop_no ?? ''),
|
||||
extra_refund_type: String(extra.refund_type ?? ''),
|
||||
extra_token_refresh_margin_seconds: Number(extra.token_refresh_margin_seconds ?? 300),
|
||||
extra_terminal_id: String(extra.terminal_id ?? ''),
|
||||
extra_device_no: String(extra.device_no ?? ''),
|
||||
extra_expire_seconds: Number(extra.expire_seconds ?? 900),
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
@@ -346,6 +371,14 @@ export function AdminPaymentConfigsPage() {
|
||||
extra.refund_type = values.extra_refund_type
|
||||
}
|
||||
extraConfig = extra
|
||||
} else if (values.provider === 'saobei') {
|
||||
extraConfig = {
|
||||
terminal_id: values.extra_terminal_id?.trim() ?? '',
|
||||
expire_seconds: values.extra_expire_seconds ?? 900,
|
||||
}
|
||||
if (values.extra_device_no?.trim()) {
|
||||
extraConfig.device_no = values.extra_device_no.trim()
|
||||
}
|
||||
}
|
||||
const payload: AdminPaymentConfigPayload = {
|
||||
name: values.name.trim(),
|
||||
@@ -692,7 +725,7 @@ export function AdminPaymentConfigsPage() {
|
||||
description={
|
||||
editing
|
||||
? 'SignKey / NotifyKey 留空表示保持原值;额外配置需提交完整 JSON。'
|
||||
: '乐刷/拉卡拉/顺诚需要完整密钥与回调地址;创建前请确认商户环境。'
|
||||
: '乐刷/拉卡拉/顺诚/扫呗需要完整密钥与回调地址;创建前请确认商户环境。'
|
||||
}
|
||||
/>
|
||||
{providerValue === 'mock' ? (
|
||||
@@ -704,6 +737,15 @@ export function AdminPaymentConfigsPage() {
|
||||
description="无需网关、密钥和回调地址;用户发起支付后会直接标记为已支付,请勿在生产环境启用。"
|
||||
/>
|
||||
) : null}
|
||||
{isSaobei ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="扫呗聚合码支付"
|
||||
description="使用普通支付 2.0 的 /pay/open/qrpay 创建收银台链接。接口域名、商户号、终端号和 access_token 必须与扫呗技术人员提供的环境一致。"
|
||||
/>
|
||||
) : null}
|
||||
<Form<ConfigFormValues> form={form} layout="vertical" disabled={submitting}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
@@ -754,7 +796,10 @@ export function AdminPaymentConfigsPage() {
|
||||
label="网关地址"
|
||||
rules={[{ required: true, message: '请输入支付网关地址' }]}
|
||||
>
|
||||
<Input maxLength={512} />
|
||||
<Input
|
||||
maxLength={512}
|
||||
placeholder={isSaobei ? '请输入扫呗技术人员提供的接口域名' : 'https://支付网关地址'}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
@@ -800,6 +845,30 @@ export function AdminPaymentConfigsPage() {
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{isSaobei ? (
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="extra_terminal_id"
|
||||
label="终端号"
|
||||
rules={[{ required: true, message: '请输入扫呗终端号' }]}
|
||||
>
|
||||
<Input maxLength={8} placeholder="terminal_id" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="extra_device_no" label="设备号" extra="平台已配置映射时才填写">
|
||||
<Input maxLength={32} placeholder="device_no(可选)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="extra_expire_seconds" label="链接有效期(秒)">
|
||||
<InputNumber min={1} max={7200} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{!isMock ? (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
@@ -812,7 +881,11 @@ export function AdminPaymentConfigsPage() {
|
||||
: [
|
||||
{
|
||||
required: true,
|
||||
message: isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥',
|
||||
message: isLakala
|
||||
? '请输入商户私钥 PEM'
|
||||
: isSaobei
|
||||
? '请输入扫呗终端密钥'
|
||||
: '请输入签名密钥',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -825,37 +898,42 @@ export function AdminPaymentConfigsPage() {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
) : (
|
||||
<Input.Password placeholder="请输入签名密钥" autoComplete="new-password" />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="notify_key"
|
||||
label={notifyKeyLabel}
|
||||
rules={
|
||||
editing
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
required: true,
|
||||
message: isLakala ? '请输入通知验签证书 PEM' : '请输入通知密钥',
|
||||
},
|
||||
]
|
||||
}
|
||||
extra={editing ? '留空不修改' : undefined}
|
||||
>
|
||||
{isLakala ? (
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
placeholder="请输入拉卡拉通知验签证书 PEM"
|
||||
<Input.Password
|
||||
placeholder={isSaobei ? '请输入终端密钥 access_token' : '请输入签名密钥'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
) : (
|
||||
<Input.Password placeholder="请输入通知密钥" autoComplete="new-password" />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{!isSaobei ? (
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="notify_key"
|
||||
label={notifyKeyLabel}
|
||||
rules={
|
||||
editing
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
required: true,
|
||||
message: isLakala ? '请输入通知验签证书 PEM' : '请输入通知密钥',
|
||||
},
|
||||
]
|
||||
}
|
||||
extra={editing ? '留空不修改' : undefined}
|
||||
>
|
||||
{isLakala ? (
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
placeholder="请输入拉卡拉通知验签证书 PEM"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
) : (
|
||||
<Input.Password placeholder="请输入通知密钥" autoComplete="new-password" />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@ const bizTypeOptions = [
|
||||
]
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'leshua', label: '拉卡拉' },
|
||||
{ value: 'leshua', label: '乐刷' },
|
||||
{ value: 'lakala', label: '拉卡拉' },
|
||||
{ value: 'shuncheng', label: '顺诚' },
|
||||
{ value: 'saobei', label: '扫呗' },
|
||||
{ value: 'mock', label: '模拟支付' },
|
||||
]
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Alert, App, Button, Card, Image, InputNumber, Space, Tag, Typography }
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { createCrashOrder, startCrashPayment, useCrashProduct } from '@/features/crash/api'
|
||||
import {
|
||||
crashPaymentURL,
|
||||
createCrashOrder,
|
||||
startCrashPayment,
|
||||
useCrashProduct,
|
||||
} from '@/features/crash/api'
|
||||
import { formatMoney } from '@/shared/utils/format'
|
||||
|
||||
function errorText(error: unknown) {
|
||||
@@ -28,8 +33,9 @@ export function CrashDetailPage() {
|
||||
message.success('下单成功')
|
||||
try {
|
||||
const payment = await startCrashPayment(order.id)
|
||||
if (payment?.pay_url) {
|
||||
window.location.href = payment.pay_url
|
||||
const payURL = crashPaymentURL(payment)
|
||||
if (payURL) {
|
||||
window.location.href = payURL
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -4,7 +4,12 @@ import { Alert, App, Button, Card, Empty, Image, Space, Tag, Typography } from '
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { cancelCrashOrder, startCrashPayment, useMyCrashOrders } from '@/features/crash/api'
|
||||
import {
|
||||
cancelCrashOrder,
|
||||
crashPaymentURL,
|
||||
startCrashPayment,
|
||||
useMyCrashOrders,
|
||||
} from '@/features/crash/api'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatMoney } from '@/shared/utils/format'
|
||||
|
||||
@@ -104,8 +109,9 @@ export function CrashOrdersPage() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
const payment = await startCrashPayment(order.id)
|
||||
if (payment?.pay_url) {
|
||||
window.location.href = payment.pay_url
|
||||
const payURL = crashPaymentURL(payment)
|
||||
if (payURL) {
|
||||
window.location.href = payURL
|
||||
} else {
|
||||
message.success('支付已发起,请在支付渠道完成付款')
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CrashProduct,
|
||||
CrashProductPage,
|
||||
} from '@/features/crash/types'
|
||||
import type { UserPayment } from '@/features/orders/types'
|
||||
|
||||
export function fetchCrashCategories() {
|
||||
return unwrapData<CrashCategory[]>(
|
||||
@@ -90,9 +91,13 @@ export function cancelCrashOrder(id: number) {
|
||||
}
|
||||
|
||||
export function startCrashPayment(id: number) {
|
||||
return unwrapData<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }>(
|
||||
apiClient.post<
|
||||
ApiResponse<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }>
|
||||
>(`/crash/orders/${id}/start-payment`)
|
||||
return unwrapData<UserPayment>(
|
||||
apiClient.post<ApiResponse<UserPayment>>(`/crash/orders/${id}/start-payment`)
|
||||
)
|
||||
}
|
||||
|
||||
export function crashPaymentURL(
|
||||
payment?: Pick<UserPayment, 'jspay_url' | 'td_code' | 'jspay_info'>
|
||||
) {
|
||||
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useState } from 'react'
|
||||
import { Button, Card, Dialog, Image, NavBar, Stepper, Toast } from 'antd-mobile'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { createCrashOrder, startCrashPayment, useCrashProduct } from '@/features/crash/api'
|
||||
import {
|
||||
crashPaymentURL,
|
||||
createCrashOrder,
|
||||
startCrashPayment,
|
||||
useCrashProduct,
|
||||
} from '@/features/crash/api'
|
||||
import { formatMoney } from '@/shared/utils/format'
|
||||
|
||||
function errorText(error: unknown) {
|
||||
@@ -25,8 +30,9 @@ export function MobileCrashDetailPage() {
|
||||
const order = await createCrashOrder({ product_id: product.id, quantity })
|
||||
try {
|
||||
const payment = await startCrashPayment(order.id)
|
||||
if (payment?.pay_url) {
|
||||
window.location.href = payment.pay_url
|
||||
const payURL = crashPaymentURL(payment)
|
||||
if (payURL) {
|
||||
window.location.href = payURL
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Button, Card, Dialog, ErrorBlock, Image, NavBar, Skeleton, Tag, Toast }
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { cancelCrashOrder, startCrashPayment, useMyCrashOrders } from '@/features/crash/api'
|
||||
import {
|
||||
cancelCrashOrder,
|
||||
crashPaymentURL,
|
||||
startCrashPayment,
|
||||
useMyCrashOrders,
|
||||
} from '@/features/crash/api'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatMoney } from '@/shared/utils/format'
|
||||
|
||||
@@ -78,8 +83,9 @@ export function MobileCrashOrdersPage() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
const payment = await startCrashPayment(order.id)
|
||||
if (payment?.pay_url) {
|
||||
window.location.href = payment.pay_url
|
||||
const payURL = crashPaymentURL(payment)
|
||||
if (payURL) {
|
||||
window.location.href = payURL
|
||||
} else {
|
||||
Toast.show({ content: '支付已发起,请在支付渠道完成付款' })
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
QRCode,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
@@ -508,10 +509,11 @@ export function OrderActionPanel({
|
||||
showIcon
|
||||
message="支付单已创建"
|
||||
description={
|
||||
<Space direction="vertical">
|
||||
<span>请在支付页面完成付款,完成后回到本页检查支付状态。</span>
|
||||
<Space direction="vertical" align="center">
|
||||
<QRCode value={payURL} size={180} />
|
||||
<span>请使用微信、支付宝或云闪付扫码付款,完成后回到本页检查支付状态。</span>
|
||||
<Button type="primary" href={payURL} target="_blank">
|
||||
打开支付页面
|
||||
在支付应用中打开
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user