feat(waffo): Waffo payment gateway integration with configurable methods

- Add Waffo payment SDK integration (waffo-go v1.3.1)
- Backend: webhook handler, pay endpoint, order lock race-condition fix
- Settings: full Waffo config (API keys, sandbox/prod, currency, pay methods)
- Frontend: Waffo payment buttons in topup page, admin settings panel
- i18n: Waffo-related translations for en/fr/ja/ru/vi/zh-TW

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyuan.zhao
2026-03-17 18:04:58 +08:00
parent 620e066b39
commit 202a433f86
24 changed files with 1472 additions and 89 deletions

View File

@@ -0,0 +1,607 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import {
Banner,
Button,
Form,
Row,
Col,
Typography,
Spin,
Table,
Modal,
Input,
Space,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function SettingsPaymentGatewayWaffo(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
WaffoEnabled: false,
WaffoApiKey: '',
WaffoPrivateKey: '',
WaffoPublicCert: '',
WaffoSandboxPublicCert: '',
WaffoSandboxApiKey: '',
WaffoSandboxPrivateKey: '',
WaffoSandbox: false,
WaffoMerchantId: '',
WaffoCurrency: 'USD',
WaffoUnitPrice: 1.0,
WaffoMinTopUp: 1,
WaffoNotifyUrl: '',
WaffoReturnUrl: '',
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
const iconFileInputRef = useRef(null);
const handleIconFileChange = (e) => {
const file = e.target.files[0];
if (!file) return;
const MAX_ICON_SIZE = 100 * 1024; // 100 KB
if (file.size > MAX_ICON_SIZE) {
showError(t('图标文件不能超过 100KB请压缩后重新上传'));
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (event) => {
setPayMethodForm((prev) => ({ ...prev, icon: event.target.result }));
};
reader.readAsDataURL(file);
e.target.value = '';
};
// 支付方式列表
const [waffoPayMethods, setWaffoPayMethods] = useState([]);
// 支付方式弹窗
const [payMethodModalVisible, setPayMethodModalVisible] = useState(false);
// 当前编辑的索引,-1 表示新增
const [editingPayMethodIndex, setEditingPayMethodIndex] = useState(-1);
// 弹窗内表单字段的临时状态
const [payMethodForm, setPayMethodForm] = useState({
name: '',
icon: '',
payMethodType: '',
payMethodName: '',
});
useEffect(() => {
if (props.options && formApiRef.current) {
const currentInputs = {
WaffoEnabled: props.options.WaffoEnabled === 'true' || props.options.WaffoEnabled === true,
WaffoApiKey: props.options.WaffoApiKey || '',
WaffoPrivateKey: props.options.WaffoPrivateKey || '',
WaffoPublicCert: props.options.WaffoPublicCert || '',
WaffoSandboxPublicCert: props.options.WaffoSandboxPublicCert || '',
WaffoSandboxApiKey: props.options.WaffoSandboxApiKey || '',
WaffoSandboxPrivateKey: props.options.WaffoSandboxPrivateKey || '',
WaffoSandbox: props.options.WaffoSandbox === 'true',
WaffoMerchantId: props.options.WaffoMerchantId || '',
WaffoCurrency: props.options.WaffoCurrency || 'USD',
WaffoUnitPrice: parseFloat(props.options.WaffoUnitPrice) || 1.0,
WaffoMinTopUp: parseInt(props.options.WaffoMinTopUp) || 1,
WaffoNotifyUrl: props.options.WaffoNotifyUrl || '',
WaffoReturnUrl: props.options.WaffoReturnUrl || '',
};
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
// 解析支付方式列表
try {
const rawPayMethods = props.options.WaffoPayMethods;
if (rawPayMethods) {
const parsed = JSON.parse(rawPayMethods);
if (Array.isArray(parsed)) {
setWaffoPayMethods(parsed);
}
}
} catch {
setWaffoPayMethods([]);
}
}
}, [props.options]);
const handleFormChange = (values) => {
setInputs(values);
};
const submitWaffoSetting = async () => {
setLoading(true);
try {
const options = [];
options.push({
key: 'WaffoEnabled',
value: inputs.WaffoEnabled ? 'true' : 'false',
});
if (inputs.WaffoApiKey && inputs.WaffoApiKey !== '') {
options.push({ key: 'WaffoApiKey', value: inputs.WaffoApiKey });
}
if (inputs.WaffoPrivateKey && inputs.WaffoPrivateKey !== '') {
options.push({ key: 'WaffoPrivateKey', value: inputs.WaffoPrivateKey });
}
options.push({ key: 'WaffoPublicCert', value: inputs.WaffoPublicCert || '' });
options.push({ key: 'WaffoSandboxPublicCert', value: inputs.WaffoSandboxPublicCert || '' });
if (inputs.WaffoSandboxApiKey && inputs.WaffoSandboxApiKey !== '') {
options.push({ key: 'WaffoSandboxApiKey', value: inputs.WaffoSandboxApiKey });
}
if (inputs.WaffoSandboxPrivateKey && inputs.WaffoSandboxPrivateKey !== '') {
options.push({ key: 'WaffoSandboxPrivateKey', value: inputs.WaffoSandboxPrivateKey });
}
options.push({
key: 'WaffoSandbox',
value: inputs.WaffoSandbox ? 'true' : 'false',
});
options.push({ key: 'WaffoMerchantId', value: inputs.WaffoMerchantId || '' });
options.push({ key: 'WaffoCurrency', value: inputs.WaffoCurrency || '' });
options.push({
key: 'WaffoUnitPrice',
value: String(inputs.WaffoUnitPrice || 1.0),
});
options.push({
key: 'WaffoMinTopUp',
value: String(inputs.WaffoMinTopUp || 1),
});
options.push({ key: 'WaffoNotifyUrl', value: inputs.WaffoNotifyUrl || '' });
options.push({ key: 'WaffoReturnUrl', value: inputs.WaffoReturnUrl || '' });
// 保存支付方式列表
options.push({
key: 'WaffoPayMethods',
value: JSON.stringify(waffoPayMethods),
});
// 发送请求
const requestQueue = options.map((opt) =>
API.put('/api/option/', {
key: opt.key,
value: opt.value,
}),
);
const results = await Promise.all(requestQueue);
// 检查所有请求是否成功
const errorResults = results.filter((res) => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach((res) => {
showError(res.data.message);
});
} else {
showSuccess(t('更新成功'));
// 更新本地存储的原始值
setOriginInputs({ ...inputs });
props.refresh?.();
}
} catch (error) {
showError(t('更新失败'));
}
setLoading(false);
};
// 打开新增弹窗
const openAddPayMethodModal = () => {
setEditingPayMethodIndex(-1);
setPayMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' });
setPayMethodModalVisible(true);
};
// 打开编辑弹窗
const openEditPayMethodModal = (record, index) => {
setEditingPayMethodIndex(index);
setPayMethodForm({
name: record.name || '',
icon: record.icon || '',
payMethodType: record.payMethodType || '',
payMethodName: record.payMethodName || '',
});
setPayMethodModalVisible(true);
};
// 确认保存弹窗(新增或编辑)
const handlePayMethodModalOk = () => {
if (!payMethodForm.name || payMethodForm.name.trim() === '') {
showError(t('支付方式名称不能为空'));
return;
}
const newMethod = {
name: payMethodForm.name.trim(),
icon: payMethodForm.icon.trim(),
payMethodType: payMethodForm.payMethodType.trim(),
payMethodName: payMethodForm.payMethodName.trim(),
};
if (editingPayMethodIndex === -1) {
// 新增
setWaffoPayMethods([...waffoPayMethods, newMethod]);
} else {
// 编辑
const updated = [...waffoPayMethods];
updated[editingPayMethodIndex] = newMethod;
setWaffoPayMethods(updated);
}
setPayMethodModalVisible(false);
};
// 删除支付方式
const handleDeletePayMethod = (index) => {
const updated = waffoPayMethods.filter((_, i) => i !== index);
setWaffoPayMethods(updated);
};
// 支付方式表格列定义
const payMethodColumns = [
{
title: t('显示名称'),
dataIndex: 'name',
},
{
title: t('图标'),
dataIndex: 'icon',
render: (text) =>
text ? (
<img
src={text}
alt='icon'
style={{ width: 24, height: 24, objectFit: 'contain' }}
/>
) : (
<Text type='tertiary'></Text>
),
},
{
title: t('支付方式类型'),
dataIndex: 'payMethodType',
render: (text) => text || <Text type='tertiary'></Text>,
},
{
title: t('支付方式名称'),
dataIndex: 'payMethodName',
render: (text) => text || <Text type='tertiary'></Text>,
},
{
title: t('操作'),
key: 'action',
render: (_, record, index) => (
<Space>
<Button
size='small'
onClick={() => openEditPayMethodModal(record, index)}
>
{t('编辑')}
</Button>
<Button
size='small'
type='danger'
onClick={() => handleDeletePayMethod(index)}
>
{t('删除')}
</Button>
</Space>
),
},
];
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Waffo 设置')}>
<Text>
{t('Waffo 是一个支付聚合平台,支持多种支付方式。')}
<a href='https://waffo.com' target='_blank' rel='noreferrer'>
Waffo Official Site
</a>
<br />
</Text>
<Banner
type='info'
description={t(
'请在 Waffo 后台获取 API 密钥、商户 ID 以及 RSA 密钥对,并配置回调地址。',
)}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoEnabled'
label={t('启用 Waffo')}
size='default'
checkedText=''
uncheckedText=''
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoSandbox'
label={t('沙盒模式')}
size='default'
checkedText=''
uncheckedText=''
extraText={t('启用后将使用 Waffo 沙盒环境')}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoApiKey'
label={t('API 密钥 (生产)')}
placeholder={t('生产环境 Waffo API 密钥')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoSandboxApiKey'
label={t('API 密钥 (沙盒)')}
placeholder={t('沙盒环境 Waffo API 密钥')}
type='password'
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoMerchantId'
label={t('商户 ID')}
placeholder={t('Waffo 商户 ID')}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPrivateKey'
label={t('RSA 私钥 (生产)')}
placeholder={t('生产环境 RSA 私钥 Base64 (PKCS#8 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoSandboxPrivateKey'
label={t('RSA 私钥 (沙盒)')}
placeholder={t('沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPublicCert'
label={t('Waffo 公钥 (生产)')}
placeholder={t('生产环境 Waffo 公钥 Base64 (X.509 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoSandboxPublicCert'
label={t('Waffo 公钥 (沙盒)')}
placeholder={t('沙盒环境 Waffo 公钥 Base64 (X.509 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoCurrency'
label={t('货币')}
disabled
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoUnitPrice'
label={t('单价 (USD)')}
placeholder='1.0'
min={0}
step={0.1}
extraText={t('每个充值单位对应的 USD 金额,默认 1.0')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoMinTopUp'
label={t('最低充值数量')}
placeholder='1'
min={1}
step={1}
extraText={t('Waffo 充值的最低数量,默认 1')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoNotifyUrl'
label={t('回调通知地址')}
placeholder={t('例如 https://example.com/api/waffo/webhook')}
extraText={t('留空则自动使用 服务器地址 + /api/waffo/webhook')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoReturnUrl'
label={t('支付返回地址')}
placeholder={t('例如 https://example.com/console/topup')}
extraText={t('支付完成后用户跳转的页面,留空则自动使用 服务器地址 + /console/topup')}
/>
</Col>
</Row>
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
{t('更新 Waffo 设置')}
</Button>
</Form.Section>
</Form>
{/* 支付方式配置区块(独立于 Form使用独立状态管理 */}
<div style={{ marginTop: 24 }}>
<Typography.Title heading={6} style={{ marginBottom: 8 }}>{t('支付方式')}</Typography.Title>
<Text type='secondary'>
{t('配置 Waffo 充值时可用的支付方式,保存后在充值页面展示给用户。')}
</Text>
<div style={{ marginTop: 12, marginBottom: 12 }}>
<Button onClick={openAddPayMethodModal}>
{t('新增支付方式')}
</Button>
</div>
<Table
columns={payMethodColumns}
dataSource={waffoPayMethods}
rowKey={(record, index) => index}
pagination={false}
size='small'
empty={<Text type='tertiary'>{t('暂无支付方式,点击上方按钮新增')}</Text>}
/>
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
{t('更新 Waffo 设置')}
</Button>
</div>
{/* 新增/编辑支付方式弹窗 */}
<Modal
title={editingPayMethodIndex === -1 ? t('新增支付方式') : t('编辑支付方式')}
visible={payMethodModalVisible}
onOk={handlePayMethodModalOk}
onCancel={() => setPayMethodModalVisible(false)}
okText={t('确定')}
cancelText={t('取消')}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('显示名称')}</Text>
<span style={{ color: 'var(--semi-color-danger)', marginLeft: 4 }}>*</span>
</div>
<Input
value={payMethodForm.name}
onChange={(val) => setPayMethodForm({ ...payMethodForm, name: val })}
placeholder={t('例如Credit Card')}
/>
<Text type='tertiary' size='small'>{t('用户在充值页面看到的支付方式名称例如Credit Card')}</Text>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('图标')}</Text>
</div>
<Space align='center'>
{payMethodForm.icon && (
<img
src={payMethodForm.icon}
alt='preview'
style={{
width: 32,
height: 32,
objectFit: 'contain',
border: '1px solid var(--semi-color-border)',
borderRadius: 4,
}}
/>
)}
<input
type='file'
accept='image/*'
ref={iconFileInputRef}
style={{ display: 'none' }}
onChange={handleIconFileChange}
/>
<Button
size='small'
onClick={() => iconFileInputRef.current?.click()}
>
{payMethodForm.icon ? t('重新上传') : t('上传图片')}
</Button>
{payMethodForm.icon && (
<Button
size='small'
type='danger'
onClick={() =>
setPayMethodForm((prev) => ({ ...prev, icon: '' }))
}
>
{t('清除')}
</Button>
)}
</Space>
<div>
<Text type='tertiary' size='small'>{t('上传 PNG/JPG/SVG 图片,建议尺寸 ≤ 128×128px')}</Text>
</div>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('Pay Method Type')}</Text>
</div>
<Input
value={payMethodForm.payMethodType}
onChange={(val) => setPayMethodForm({ ...payMethodForm, payMethodType: val })}
placeholder='CREDITCARD,DEBITCARD'
maxLength={64}
/>
<Text type='tertiary' size='small'>{t('Waffo API 参数可空例如CREDITCARD,DEBITCARD最多64位')}</Text>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('Pay Method Name')}</Text>
</div>
<Input
value={payMethodForm.payMethodName}
onChange={(val) => setPayMethodForm({ ...payMethodForm, payMethodName: val })}
placeholder={t('可空')}
maxLength={64}
/>
<Text type='tertiary' size='small'>{t('Waffo API 参数可空最多64位')}</Text>
</div>
</div>
</Modal>
</Spin>
);
}