[Feature Request] Waffo Pancake gateway — full integration with subscription support + admin catalog binding flow (#4935)

This commit is contained in:
Hill-waffo
2026-05-22 11:00:58 +08:00
committed by GitHub
parent 8e5e89bb5b
commit 19f1821fc8
45 changed files with 2437 additions and 1091 deletions
+7 -22
View File
@@ -53,16 +53,9 @@ const PaymentSetting = () => {
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
'payment_setting.compliance_confirmed': false,
'payment_setting.compliance_terms_version': '',
'payment_setting.compliance_confirmed_at': 0,
@@ -171,21 +164,13 @@ const PaymentSetting = () => {
case 'MinTopUp':
case 'StripeUnitPrice':
case 'StripeMinTopUp':
case 'WaffoPancakeUnitPrice':
case 'WaffoPancakeMinTopUp':
newInputs[item.key] = parseFloat(item.value);
break;
case 'WaffoPancakeMerchantID':
case 'WaffoPancakePrivateKey':
case 'WaffoPancakeStoreID':
case 'WaffoPancakeProductID':
case 'WaffoPancakeReturnURL':
case 'WaffoPancakeCurrency':
newInputs[item.key] = item.value;
break;
case 'WaffoPancakeSandbox':
newInputs[item.key] = toBoolean(item.value);
break;
default:
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value);
@@ -320,6 +305,13 @@ const PaymentSetting = () => {
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>
<SettingsPaymentGatewayWaffoPancake
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
<SettingsPaymentGatewayWaffo
options={inputs}
@@ -327,13 +319,6 @@ const PaymentSetting = () => {
hideSectionTitle
/>
</Tabs.TabPane>
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
{/* <SettingsPaymentGatewayWaffoPancake*/}
{/* options={inputs}*/}
{/* refresh={onRefresh}*/}
{/* hideSectionTitle*/}
{/* />*/}
{/*</Tabs.TabPane>*/}
</Tabs>
</div>
</Card>
+14 -3
View File
@@ -47,6 +47,7 @@ import {
} from 'lucide-react';
import { IconGift } from '@douyinfe/semi-icons';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { useActualTheme } from '../../context/Theme';
import { getCurrencyConfig } from '../../helpers/render';
import SubscriptionPlansCard from './SubscriptionPlansCard';
@@ -102,6 +103,7 @@ const RechargeCard = ({
const redeemFormApiRef = useRef(null);
const initialTabSetRef = useRef(false);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
const actualTheme = useActualTheme();
const [activeTab, setActiveTab] = useState('topup');
const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0;
@@ -355,9 +357,18 @@ const RechargeCard = ({
}}
/>
) : payMethod.type === 'waffo_pancake' ? (
<CreditCard
size={18}
color='var(--semi-color-primary)'
<img
src={
actualTheme === 'dark'
? '/waffo-logo-dark.svg'
: '/waffo-logo-light.svg'
}
alt='Waffo'
style={{
width: 18,
height: 18,
objectFit: 'contain',
}}
/>
) : (
<CreditCard
+23 -2
View File
@@ -40,6 +40,23 @@ import TransferModal from './modals/TransferModal';
import PaymentConfirmModal from './modals/PaymentConfirmModal';
import TopupHistoryModal from './modals/TopupHistoryModal';
// Reject non-navigable schemes (e.g. javascript:, data:) and relative URLs.
// Only http / https are allowed for backend-provided redirect targets.
// Mirrors isSafeHttpCheckoutUrl in the default frontend's
// features/wallet/hooks/use-waffo-pancake-payment.ts.
function isSafeHttpCheckoutUrl(value) {
const trimmed = (value || '').trim();
if (!trimmed) {
return false;
}
try {
const u = new URL(trimmed);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
const TopUp = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -454,8 +471,12 @@ const TopUp = () => {
const { message, data } = res.data;
if (message === 'success') {
const checkoutUrl = data?.checkout_url || '';
if (checkoutUrl) {
window.open(checkoutUrl, '_blank');
if (checkoutUrl && isSafeHttpCheckoutUrl(checkoutUrl)) {
// In-tab redirect (not window.open) — popup blocker fires after
// the await loses user-gesture context.
window.location.href = checkoutUrl;
} else if (checkoutUrl) {
showError(t('支付跳转地址不安全'));
} else {
showError(t('支付请求失败'));
}
+1
View File
@@ -1720,6 +1720,7 @@
"支付渠道": "Payment Channels",
"支付设置": "Payment",
"支付请求失败": "Payment request failed",
"支付跳转地址不安全": "Unsafe payment redirect URL",
"支付返回地址": "Return URL",
"支付金额": "Payment Amount",
"支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images",
+1
View File
@@ -1154,6 +1154,7 @@
"支付方式": "支付方式",
"支付设置": "支付设置",
"支付请求失败": "支付请求失败",
"支付跳转地址不安全": "支付跳转地址不安全",
"支付金额": "支付金额",
"支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片",
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。",
@@ -26,25 +26,14 @@ import {
showSuccess,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { BookOpen, TriangleAlert } from 'lucide-react';
import { BookOpen } from 'lucide-react';
const defaultInputs = {
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeWebhookPublicKey: '',
WaffoPancakeWebhookTestKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
};
const toBoolean = (value) => value === true || value === 'true';
export default function SettingsPaymentGatewayWaffoPancake(props) {
const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle
@@ -58,26 +47,9 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
if (!props.options || !formApiRef.current) return;
const currentInputs = {
WaffoPancakeEnabled: toBoolean(props.options.WaffoPancakeEnabled),
WaffoPancakeSandbox: toBoolean(props.options.WaffoPancakeSandbox),
WaffoPancakeMerchantID: props.options.WaffoPancakeMerchantID || '',
WaffoPancakePrivateKey: props.options.WaffoPancakePrivateKey || '',
WaffoPancakeWebhookPublicKey:
props.options.WaffoPancakeWebhookPublicKey || '',
WaffoPancakeWebhookTestKey:
props.options.WaffoPancakeWebhookTestKey || '',
WaffoPancakeStoreID: props.options.WaffoPancakeStoreID || '',
WaffoPancakeProductID: props.options.WaffoPancakeProductID || '',
WaffoPancakeReturnURL: props.options.WaffoPancakeReturnURL || '',
WaffoPancakeCurrency: props.options.WaffoPancakeCurrency || 'USD',
WaffoPancakeUnitPrice:
props.options.WaffoPancakeUnitPrice !== undefined
? parseFloat(props.options.WaffoPancakeUnitPrice)
: 1.0,
WaffoPancakeMinTopUp:
props.options.WaffoPancakeMinTopUp !== undefined
? parseFloat(props.options.WaffoPancakeMinTopUp)
: 1,
};
setInputs(currentInputs);
@@ -93,90 +65,23 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
...inputs,
...(formApiRef.current?.getValues?.() || {}),
};
values.WaffoPancakeEnabled = toBoolean(values.WaffoPancakeEnabled);
values.WaffoPancakeSandbox = toBoolean(values.WaffoPancakeSandbox);
const currentWebhookField = values.WaffoPancakeSandbox
? 'WaffoPancakeWebhookTestKey'
: 'WaffoPancakeWebhookPublicKey';
const currentWebhookLabel = values.WaffoPancakeSandbox
? t('Webhook 公钥(测试环境)')
: t('Webhook 公钥(生产环境)');
if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) {
showError(t('请输入商户 ID'));
return;
}
if (values.WaffoPancakeEnabled && !values.WaffoPancakeStoreID.trim()) {
showError(t('请输入 Store ID'));
return;
}
if (values.WaffoPancakeEnabled && !values.WaffoPancakeProductID.trim()) {
showError(t('请输入 Product ID'));
return;
}
if (
values.WaffoPancakeEnabled &&
!String(values[currentWebhookField] || '').trim()
) {
showError(currentWebhookLabel);
return;
}
if (
values.WaffoPancakeEnabled &&
Number(values.WaffoPancakeUnitPrice) <= 0
) {
showError(t('充值价格必须大于 0'));
return;
}
if (values.WaffoPancakeEnabled && Number(values.WaffoPancakeMinTopUp) < 1) {
showError(t('最低充值美元数量必须大于 0'));
return;
}
setLoading(true);
try {
// Classic admin only persists the three operator-typed fields.
// Store/Product binding is handled exclusively by the default
// frontend's catalog flow (see waffo-pancake-settings-section.tsx)
// because picking entities from a live catalog needs the Select +
// dependent-dropdown UX that the classic Semi-UI page doesn't have.
const options = [
{
key: 'WaffoPancakeEnabled',
value: values.WaffoPancakeEnabled ? 'true' : 'false',
},
{
key: 'WaffoPancakeSandbox',
value: values.WaffoPancakeSandbox ? 'true' : 'false',
},
{
key: 'WaffoPancakeMerchantID',
value: values.WaffoPancakeMerchantID || '',
},
{
key: 'WaffoPancakeStoreID',
value: values.WaffoPancakeStoreID || '',
},
{
key: 'WaffoPancakeProductID',
value: values.WaffoPancakeProductID || '',
},
{
key: 'WaffoPancakeReturnURL',
value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''),
},
{
key: 'WaffoPancakeCurrency',
value: values.WaffoPancakeCurrency || 'USD',
},
{
key: 'WaffoPancakeUnitPrice',
value: String(values.WaffoPancakeUnitPrice),
},
{
key: 'WaffoPancakeMinTopUp',
value: String(values.WaffoPancakeMinTopUp),
},
];
if ((values.WaffoPancakePrivateKey || '').trim()) {
@@ -186,20 +91,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
});
}
if ((values.WaffoPancakeWebhookPublicKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookPublicKey',
value: values.WaffoPancakeWebhookPublicKey,
});
}
if ((values.WaffoPancakeWebhookTestKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookTestKey',
value: values.WaffoPancakeWebhookTestKey,
});
}
const results = await Promise.all(
options.map((opt) =>
API.put('/api/option/', {
@@ -237,103 +128,43 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
icon={<BookOpen size={16} />}
description={
<>
Waffo Pancake 商户商品和签名密钥请
Waffo Pancake 商户 ID 与私钥请
<a
href='https://docs.waffo.ai'
href='https://pancake.waffo.ai/merchant/dashboard'
target='_blank'
rel='noreferrer'
>
点击此处
Waffo Pancake 控制台
</a>
获取建议先在测试环境完成联调
获取保存后系统会自动在该商户名下创建 Store + Product无需手动配置
环境test / 生产由你粘贴的 API 私钥本身决定
请在 Pancake 控制台把下面两个回调地址分别注册到 Test Mode Production Mode
两个 webhook 位置分开走避免测试流量污染生产数据
<br />
{t('回调地址')}
{t('Test 回调地址')}
{props.options.ServerAddress
? removeTrailingSlash(props.options.ServerAddress)
: t('网站地址')}
/api/waffo-pancake/webhook
/api/waffo-pancake/webhook/test
<br />
{t('Production 回调地址')}
{props.options.ServerAddress
? removeTrailingSlash(props.options.ServerAddress)
: t('网站地址')}
/api/waffo-pancake/webhook/prod
</>
}
style={{ marginBottom: 12 }}
/>
<Banner
type='warning'
icon={<TriangleAlert size={16} />}
description={t(
'请确认 Merchant、Store、Product 和所选环境密钥一致。',
)}
style={{ marginBottom: 16 }}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoPancakeEnabled'
label={t('启用 Waffo Pancake')}
checkedText=''
uncheckedText=''
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoPancakeSandbox'
label={t('沙盒模式')}
checkedText=''
uncheckedText=''
extraText={t('用于切换当前下单和回调校验所使用的环境')}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeCurrency'
label={t('货币')}
placeholder='USD'
extraText={t('默认使用 USD 结算')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeMerchantID'
label={t('商户 ID')}
placeholder={t('例如:MER_xxx')}
extraText={t('请填写当前环境对应的商户 ID')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeStoreID'
label={t('Store ID')}
placeholder={t('例如:STO_xxx')}
extraText={t('请填写当前环境对应的 Store ID')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeProductID'
label={t('Product ID')}
placeholder={t('例如:PROD_xxx')}
extraText={t('请填写当前环境对应的 Product ID')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPancakePrivateKey'
label={t('API 私钥')}
placeholder={t('填写后覆盖当前私钥,留空表示保持当前不变')}
extraText={t('保存后不会回显,请填写当前环境对应的 API 私钥')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
<Form.Input
field='WaffoPancakeMerchantID'
label={t('商户 ID')}
placeholder={t('例如:MER_xxx')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
@@ -341,7 +172,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
field='WaffoPancakeReturnURL'
label={t('支付返回地址')}
placeholder={t('例如:https://example.com/console/topup')}
extraText={t('留空则自动使用当前站点的默认充值页地址')}
/>
</Col>
</Row>
@@ -350,55 +180,16 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Col xs={24}>
<Form.TextArea
field='WaffoPancakeWebhookPublicKey'
label={t('Webhook 公钥(生产环境)')}
placeholder={t(
'填写后覆盖当前生产环境 Webhook 公钥,留空表示保持当前不变',
)}
extraText={t('用于校验生产环境的 Waffo Pancake Webhook 签名')}
field='WaffoPancakePrivateKey'
label={t('API 私钥')}
placeholder={t('填写后覆盖当前私钥,留空表示保持当前不变')}
extraText={t('⚠ 测试 / 生产环境由你粘进来的 API 私钥本身决定——集成阶段用 Test Key,正式上线时再换成 Production Key')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPancakeWebhookTestKey'
label={t('Webhook 公钥(测试环境)')}
placeholder={t(
'填写后覆盖当前测试环境 Webhook 公钥,留空表示保持当前不变',
)}
extraText={t('用于校验测试环境的 Waffo Pancake Webhook 签名')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoPancakeUnitPrice'
precision={2}
label={t('充值价格(x元/美金)')}
placeholder={t('例如:7,就是7元/美金')}
extraText={t('按 1 美元对应的站内价格填写')}
min={0}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoPancakeMinTopUp'
label={t('最低充值美元数量')}
placeholder={t('例如:2,就是最低充值2$')}
extraText={t('用户单次最少可充值的美元数量')}
min={1}
/>
</Col>
</Row>
<Button onClick={submitWaffoPancakeSetting}>