mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
feat: 添加订阅购买API功能
This commit is contained in:
parent
b0bae22ad0
commit
7c0932e7b5
1
app/components/auth/PaymentDialog.tsx
Normal file
1
app/components/auth/PaymentDialog.tsx
Normal file
@ -0,0 +1 @@
|
||||
|
||||
87
app/components/auth/PaymentModal.tsx
Normal file
87
app/components/auth/PaymentModal.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface PaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
paymentData: PaymentResponse;
|
||||
onPaymentSuccess: () => void;
|
||||
}
|
||||
|
||||
interface PaymentResponse {
|
||||
status: string;
|
||||
msg: string;
|
||||
no: string;
|
||||
pay_type: string;
|
||||
order_amount: string;
|
||||
pay_amount: string;
|
||||
qr_money: string;
|
||||
qr: string;
|
||||
qr_img: string;
|
||||
did: string;
|
||||
expires_in: string;
|
||||
return_url: string;
|
||||
}
|
||||
|
||||
export function PaymentModal({ isOpen, onClose, paymentData, onPaymentSuccess }: PaymentModalProps) {
|
||||
const [timeLeft, setTimeLeft] = useState(parseInt(paymentData.expires_in));
|
||||
|
||||
const checkPaymentStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/check-payment-status?orderNo=${paymentData.no}`);
|
||||
const data = await response.json();
|
||||
if (data.status === 'completed') {
|
||||
clearInterval(timer);
|
||||
onPaymentSuccess();
|
||||
onClose();
|
||||
toast.success('支付成功!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking payment status:', error);
|
||||
}
|
||||
}, [paymentData.no, onPaymentSuccess, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft((prevTime) => {
|
||||
if (prevTime <= 1) {
|
||||
clearInterval(timer);
|
||||
onClose();
|
||||
toast.error('支付超时,请重新发起支付');
|
||||
return 0;
|
||||
}
|
||||
return prevTime - 1;
|
||||
});
|
||||
|
||||
checkPaymentStatus();
|
||||
}, 3000); // 每3秒检查一次支付状态
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isOpen, onClose, checkPaymentStatus]);
|
||||
|
||||
return (
|
||||
<DialogRoot open={isOpen}>
|
||||
<Dialog onBackdrop={onClose} onClose={onClose} className="w-full max-w-md">
|
||||
<DialogTitle>请扫码支付</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<img src={paymentData.qr_img} alt="支付二维码" className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-bolt-elements-textPrimary">订单金额: ¥{paymentData.order_amount}</p>
|
||||
<p className="text-bolt-elements-textSecondary">订单号: {paymentData.no}</p>
|
||||
<p className="text-bolt-elements-textSecondary">支付方式: {paymentData.pay_type}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-bolt-elements-textPrimary">剩余支付时间: {timeLeft}秒</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
import { toast } from 'react-toastify';
|
||||
import { PaymentModal } from './PaymentModal';
|
||||
|
||||
interface SubscriptionDialogProps {
|
||||
isOpen: boolean;
|
||||
@ -23,12 +24,28 @@ interface UserSubscription {
|
||||
nextReloadDate: string;
|
||||
}
|
||||
|
||||
interface PaymentResponse {
|
||||
status: string;
|
||||
msg: string;
|
||||
no: string;
|
||||
pay_type: string;
|
||||
order_amount: string;
|
||||
pay_amount: string;
|
||||
qr_money: string;
|
||||
qr: string;
|
||||
qr_img: string;
|
||||
did: string;
|
||||
expires_in: string;
|
||||
return_url: string;
|
||||
}
|
||||
|
||||
export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps) {
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [userSubscription, setUserSubscription] = useState<UserSubscription | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
const [paymentData, setPaymentData] = useState<PaymentResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
@ -68,107 +85,121 @@ export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps)
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
toast.success('订阅购买成功!');
|
||||
fetchSubscriptionData(); // 刷新订阅信息
|
||||
if (response.ok && result.paymentData) {
|
||||
setPaymentData(result.paymentData);
|
||||
} else {
|
||||
toast.error(result.message || '购买失败,请稍后重试。');
|
||||
toast.error(result.message || '获取支付信息失败,请稍后重试。');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error purchasing subscription:', error);
|
||||
console.error('Error initiating purchase:', error);
|
||||
toast.error('购买过程中出现错误,请稍后重试。');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = useCallback(() => {
|
||||
fetchSubscriptionData(); // 重新获取订阅信息
|
||||
toast.success('订阅成功!');
|
||||
}, [fetchSubscriptionData]);
|
||||
|
||||
if (!user || isLoading) return null;
|
||||
|
||||
return (
|
||||
<DialogRoot open={isOpen}>
|
||||
<Dialog onBackdrop={onClose} onClose={onClose} className="w-full max-w-4xl">
|
||||
<DialogTitle>订阅管理</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<p className="text-bolt-elements-textSecondary">
|
||||
注册免费账户以加速您在公共项目上的工作流程,或通过即时打开的生产环境提升整个团队的效率。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{userSubscription && (
|
||||
<div className="bg-bolt-elements-background-depth-2 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-bolt-elements-textPrimary font-bold">{userSubscription.tokensLeft.toLocaleString()}</span>
|
||||
<span className="text-bolt-elements-textSecondary"> 代币剩余。</span>
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
{userSubscription.plan.tokens.toLocaleString()}代币将在{new Date(userSubscription.nextReloadDate).toLocaleDateString()}后添加。
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-bolt-elements-textSecondary">需要更多代币?</span>
|
||||
<br />
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
升级您的计划或购买
|
||||
<a href="#" className="text-bolt-elements-item-contentAccent hover:underline">代币充值包</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<DialogRoot open={isOpen}>
|
||||
<Dialog onBackdrop={onClose} onClose={onClose} className="w-full max-w-4xl">
|
||||
<DialogTitle>订阅管理</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<p className="text-bolt-elements-textSecondary">
|
||||
注册免费账户以加速您在公共项目上的工作流程,或通过即时打开的生产环境提升整个团队的效率。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center space-x-4">
|
||||
<button
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
billingCycle === 'monthly'
|
||||
? 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
|
||||
: 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
|
||||
}`}
|
||||
>
|
||||
月付
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingCycle('yearly')}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
billingCycle === 'yearly'
|
||||
? 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
|
||||
: 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
|
||||
}`}
|
||||
>
|
||||
年付
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{subscriptionPlans.map((plan) => (
|
||||
<div key={plan._id} className={`bg-bolt-elements-background-depth-2 p-4 rounded-lg ${plan._id === userSubscription?.plan._id ? 'border-2 border-bolt-elements-item-contentAccent' : ''}`}>
|
||||
<h3 className="text-bolt-elements-textPrimary font-bold text-lg">{plan.name}</h3>
|
||||
<div className="text-bolt-elements-textSecondary mb-2">
|
||||
{(plan.tokens / 1000000).toFixed(0)}M 代币
|
||||
{plan.save_percentage && (
|
||||
<span className="ml-2 text-green-500">节省 {plan.save_percentage}%</span>
|
||||
)}
|
||||
{userSubscription && (
|
||||
<div className="bg-bolt-elements-background-depth-2 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-bolt-elements-textPrimary font-bold">{userSubscription.tokensLeft.toLocaleString()}</span>
|
||||
<span className="text-bolt-elements-textSecondary"> 代币剩余。</span>
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
{userSubscription.plan.tokens.toLocaleString()}代币将在{new Date(userSubscription.nextReloadDate).toLocaleDateString()}后添加。
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-bolt-elements-textSecondary">需要更多代币?</span>
|
||||
<br />
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
升级您的计划或购买
|
||||
<a href="#" className="text-bolt-elements-item-contentAccent hover:underline">代币充值包</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-bolt-elements-textTertiary text-sm mb-4">{plan.description}</p>
|
||||
<div className="text-bolt-elements-textPrimary font-bold text-2xl mb-2">
|
||||
¥{plan.price * (billingCycle === 'yearly' ? 10 : 1)}/{billingCycle === 'yearly' ? '年' : '月'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePurchase(plan._id)}
|
||||
className={`w-full py-2 rounded-md ${
|
||||
plan._id === userSubscription?.plan._id
|
||||
? 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
|
||||
: 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
|
||||
}`}
|
||||
>
|
||||
{plan._id === userSubscription?.plan._id ? '管理当前计划' : `升级到${plan.name}`}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="flex justify-center space-x-4">
|
||||
<button
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
billingCycle === 'monthly'
|
||||
? 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
|
||||
: 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
|
||||
}`}
|
||||
>
|
||||
月付
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingCycle('yearly')}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
billingCycle === 'yearly'
|
||||
? 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
|
||||
: 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
|
||||
}`}
|
||||
>
|
||||
年付
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{subscriptionPlans.map((plan) => (
|
||||
<div key={plan._id} className={`bg-bolt-elements-background-depth-2 p-4 rounded-lg ${plan._id === userSubscription?.plan._id ? 'border-2 border-bolt-elements-item-contentAccent' : ''}`}>
|
||||
<h3 className="text-bolt-elements-textPrimary font-bold text-lg">{plan.name}</h3>
|
||||
<div className="text-bolt-elements-textSecondary mb-2">
|
||||
{(plan.tokens / 1000000).toFixed(0)}M 代币
|
||||
{plan.save_percentage && (
|
||||
<span className="ml-2 text-green-500">节省 {plan.save_percentage}%</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-bolt-elements-textTertiary text-sm mb-4">{plan.description}</p>
|
||||
<div className="text-bolt-elements-textPrimary font-bold text-2xl mb-2">
|
||||
¥{plan.price * (billingCycle === 'yearly' ? 10 : 1)}/{billingCycle === 'yearly' ? '年' : '月'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePurchase(plan._id)}
|
||||
className={`w-full py-2 rounded-md ${
|
||||
plan._id === userSubscription?.plan._id
|
||||
? 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
|
||||
: 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
|
||||
}`}
|
||||
>
|
||||
{plan._id === userSubscription?.plan._id ? '管理当前计划' : `升级到${plan.name}`}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
</DialogDescription>
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
{paymentData && (
|
||||
<PaymentModal
|
||||
isOpen={!!paymentData}
|
||||
onClose={() => setPaymentData(null)}
|
||||
paymentData={paymentData}
|
||||
onPaymentSuccess={handlePaymentSuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
26
app/routes/api.check-payment-status.ts
Normal file
26
app/routes/api.check-payment-status.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { db } from '~/utils/db.server';
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const url = new URL(request.url);
|
||||
const orderNo = url.searchParams.get('orderNo');
|
||||
|
||||
if (!orderNo) {
|
||||
return json({ error: 'Order number is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const transaction = await db('user_transactions')
|
||||
.where('transaction_id', orderNo)
|
||||
.first();
|
||||
|
||||
if (!transaction) {
|
||||
return json({ error: 'Transaction not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ status: transaction.status });
|
||||
} catch (error) {
|
||||
console.error('Error checking payment status:', error);
|
||||
return json({ error: 'Failed to check payment status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
45
app/routes/api.payment-notify.ts
Normal file
45
app/routes/api.payment-notify.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { db } from '~/utils/db.server';
|
||||
import SDPay, { type SDNotifyBody } from '~/utils/SDPay.server';
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const formData = await request.formData();
|
||||
const notifyParams = Object.fromEntries(formData) as unknown as SDNotifyBody;
|
||||
|
||||
const sdpay = new SDPay();
|
||||
|
||||
if (!sdpay.verifyNotify(notifyParams)) {
|
||||
return json({ error: 'Invalid signature' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
// 更新交易状态
|
||||
await trx('user_transactions')
|
||||
.where('transaction_id', notifyParams.order_no)
|
||||
.update({
|
||||
status: 'completed',
|
||||
_update: db.fn.now(),
|
||||
});
|
||||
|
||||
// 获取交易详情
|
||||
const transaction = await trx('user_transactions')
|
||||
.where('transaction_id', notifyParams.order_no)
|
||||
.first();
|
||||
|
||||
if (!transaction) {
|
||||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
// 更新用户的代币余额
|
||||
await trx('users')
|
||||
.where('_id', transaction.user_id)
|
||||
.increment('token_balance', transaction.tokens);
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error processing payment notification:', error);
|
||||
return json({ error: 'Failed to process payment notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,42 +1,53 @@
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { db } from '~/lib/db.server';
|
||||
import { requireUserId } from '~/lib/session.server';
|
||||
import { db } from '~/utils/db.server';
|
||||
import { requireUserId } from '../utils/session.server'; // 使用相对路径
|
||||
import SDPay from '~/utils/SDPay.server';
|
||||
|
||||
export async function action({ request }) {
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const userId = await requireUserId(request);
|
||||
const { planId, billingCycle } = await request.json();
|
||||
const { planId, billingCycle } = await request.json() as { planId: string; billingCycle: string };
|
||||
|
||||
try {
|
||||
// 开始数据库事务
|
||||
await db.transaction(async (trx) => {
|
||||
// 获取订阅计划详情
|
||||
const plan = await trx('subscription_plans').where('_id', planId).first();
|
||||
if (!plan) {
|
||||
throw new Error('Invalid subscription plan');
|
||||
}
|
||||
// 获取订阅计划详情
|
||||
const plan = await db('subscription_plans').where('_id', planId).first();
|
||||
if (!plan) {
|
||||
return json({ error: 'Invalid subscription plan' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 计算实际价格和代币数量
|
||||
const price = billingCycle === 'yearly' ? plan.price * 10 : plan.price;
|
||||
const tokens = billingCycle === 'yearly' ? plan.tokens * 12 : plan.tokens;
|
||||
// 计算实际价格和代币数量
|
||||
const price = billingCycle === 'yearly' ? plan.price * 10 : plan.price;
|
||||
const tokens = billingCycle === 'yearly' ? plan.tokens * 12 : plan.tokens;
|
||||
|
||||
// 创建交易记录
|
||||
await trx('user_transactions').insert({
|
||||
user_id: userId,
|
||||
type: 'subscription',
|
||||
plan_id: planId,
|
||||
amount: price,
|
||||
tokens: tokens,
|
||||
status: 'completed', // 假设支付已完成
|
||||
payment_method: 'credit_card', // 假设使用信用卡支付
|
||||
transaction_id: `sub_${Date.now()}`, // 生成一个简单的交易ID
|
||||
});
|
||||
// 创建 SDPay 实例
|
||||
const sdpay = new SDPay();
|
||||
|
||||
// 这里可以添加更多逻辑,如更新用户的订阅状态等
|
||||
// 生成订单号
|
||||
const orderNo = `sub_${Date.now()}_${userId}`;
|
||||
|
||||
// 获取支付数据
|
||||
const paymentData = await sdpay.createPayment(
|
||||
orderNo,
|
||||
`${plan.name} 订阅 (${billingCycle === 'yearly' ? '年付' : '月付'})`,
|
||||
'alipay', // 或其他支付方式
|
||||
price * 100, // 转换为分
|
||||
userId.toString()
|
||||
);
|
||||
|
||||
// 创建待处理的交易记录
|
||||
await db('user_transactions').insert({
|
||||
user_id: userId,
|
||||
type: 'subscription',
|
||||
plan_id: planId,
|
||||
amount: price,
|
||||
tokens: tokens,
|
||||
status: 'pending',
|
||||
payment_method: 'alipay', // 或其他支付方式
|
||||
transaction_id: orderNo,
|
||||
});
|
||||
|
||||
return json({ success: true, message: '订阅购买成功' });
|
||||
return json({ success: true, paymentData });
|
||||
} catch (error) {
|
||||
console.error('Error purchasing subscription:', error);
|
||||
return json({ error: 'Failed to purchase subscription' }, { status: 500 });
|
||||
console.error('Error initiating subscription purchase:', error);
|
||||
return json({ error: 'Failed to initiate subscription purchase' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
4
app/types/user.ts
Normal file
4
app/types/user.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface User {
|
||||
// ... 其他字段
|
||||
token_balance: number;
|
||||
}
|
||||
123
app/utils/SDPay.server.ts
Normal file
123
app/utils/SDPay.server.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import CryptoJS from "crypto-js";
|
||||
import { toNumber } from "lodash";
|
||||
import { env } from "node:process";
|
||||
|
||||
export interface SDNotifyBody {
|
||||
no: string;
|
||||
order_no: string;
|
||||
trade_name: string;
|
||||
pay_type: string;
|
||||
order_amount: string;
|
||||
pay_amount: string;
|
||||
order_uid: string;
|
||||
sign: string;
|
||||
}
|
||||
|
||||
// 将对象转换为表单编码的字符串
|
||||
function toFormData(obj: any): string {
|
||||
return Object.keys(obj)
|
||||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
export default class SDPay {
|
||||
private appId: number;
|
||||
private apiKey: string;
|
||||
|
||||
constructor() {
|
||||
const appId = env.SDPAY_APP_ID;
|
||||
const apiKey = env.SDPAY_API_KEY;
|
||||
console.log(appId, apiKey);
|
||||
if (!appId || !apiKey) {
|
||||
throw new Error("SDPAY_APP_ID or SDPAY_API_KEY is not set");
|
||||
}
|
||||
this.appId = toNumber(appId);
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
// 发起付款
|
||||
public async createPayment(
|
||||
orderNo: string,
|
||||
tradeName: string,
|
||||
payType: string,
|
||||
orderAmount: number,
|
||||
orderUid: string,
|
||||
payerName?: string
|
||||
): Promise<any> {
|
||||
const params = {
|
||||
app_id: this.appId,
|
||||
order_no: orderNo,
|
||||
trade_name: tradeName,
|
||||
pay_type: payType,
|
||||
order_amount: orderAmount,
|
||||
order_uid: orderUid,
|
||||
// payer_name: payerName || "",
|
||||
} as any;
|
||||
|
||||
params["sign"] = this.generateSign(params);
|
||||
|
||||
// 将参数转换为表单编码的字符串
|
||||
const formData = toFormData(params);
|
||||
|
||||
const url = `https://api.sdpay.cc/pay?format=json&${formData}`;
|
||||
|
||||
console.log(url);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(response.statusText, response.text());
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取支付链接
|
||||
public async getPaymentLink(
|
||||
orderNo: string,
|
||||
tradeName: string,
|
||||
payType: string,
|
||||
orderAmount: number,
|
||||
orderUid: string,
|
||||
payerName?: string
|
||||
): Promise<string> {
|
||||
const params = {
|
||||
app_id: this.appId,
|
||||
order_no: orderNo,
|
||||
trade_name: tradeName,
|
||||
pay_type: payType,
|
||||
order_amount: orderAmount,
|
||||
order_uid: orderUid,
|
||||
// payer_name: payerName || "",
|
||||
} as any;
|
||||
|
||||
params["sign"] = this.generateSign(params); // 将参数转换为表单编码的字符串
|
||||
const formData = toFormData(params);
|
||||
const url = `https://api.sdpay.cc/pay?${formData}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
// 验证通知
|
||||
public verifyNotify(params: SDNotifyBody): boolean {
|
||||
return this.notifySign(params) === params.sign;
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
private generateSign(params: any): string {
|
||||
const signString = `app_id=${params.app_id}&order_no=${params.order_no}&trade_name=${params.trade_name}&pay_type=${params.pay_type}&order_amount=${params.order_amount}&order_uid=${params.order_uid}&${this.apiKey}`;
|
||||
|
||||
return this.md5(signString);
|
||||
}
|
||||
|
||||
private notifySign(params: SDNotifyBody): string {
|
||||
//计算签名
|
||||
// $sign_str = "no=" . $no . "&order_no=" . $order_no . "&trade_name=" . $trade_name . "&pay_type=" . $pay_type . "&order_amount=" . $order_amount . "&pay_amount=" . $pay_amount . "&order_uid=" . $order_uid . "&" . $app_key;
|
||||
// $sign = strtolower(md5($sign_str));
|
||||
const signString = `no=${params.no}&order_no=${params.order_no}&trade_name=${params.trade_name}&pay_type=${params.pay_type}&order_amount=${params.order_amount}&pay_amount=${params.pay_amount}&order_uid=${params.order_uid}&${this.apiKey}`;
|
||||
return this.md5(signString);
|
||||
}
|
||||
|
||||
// MD5加密
|
||||
private md5(string: string): string {
|
||||
return CryptoJS.MD5(string).toString();
|
||||
}
|
||||
}
|
||||
1
app/utils/session.server.ts
Normal file
1
app/utils/session.server.ts
Normal file
@ -0,0 +1 @@
|
||||
|
||||
11
db/migrations/20241022160301_add_token_balance_to_users.js
Normal file
11
db/migrations/20241022160301_add_token_balance_to_users.js
Normal file
@ -0,0 +1,11 @@
|
||||
export function up(knex) {
|
||||
return knex.schema.table('users', function(table) {
|
||||
table.bigInteger('token_balance').unsigned().notNullable().defaultTo(0).comment('用户代币余额');
|
||||
});
|
||||
}
|
||||
|
||||
export function down(knex) {
|
||||
return knex.schema.table('users', function(table) {
|
||||
table.dropColumn('token_balance');
|
||||
});
|
||||
}
|
||||
@ -3,7 +3,7 @@ export default {
|
||||
development: {
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: 'devide.y2o.me',
|
||||
host: 'localhost',
|
||||
user: 'd8d_design_ai',
|
||||
password: 'Kw8aEcm37FwNaCk6',
|
||||
database: 'd8d_design_ai',
|
||||
|
||||
@ -55,6 +55,8 @@
|
||||
"@remix-run/cloudflare-pages": "^2.10.2",
|
||||
"@remix-run/node": "^2.13.1",
|
||||
"@remix-run/react": "^2.10.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.0",
|
||||
"@unocss/reset": "^0.61.0",
|
||||
"@webcontainer/api": "1.3.0-internal.10",
|
||||
@ -64,6 +66,7 @@
|
||||
"ai": "^3.3.4",
|
||||
"ali-oss": "^6.21.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^5.2.0",
|
||||
"framer-motion": "^11.2.12",
|
||||
@ -72,6 +75,7 @@
|
||||
"jose": "^5.6.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"knex": "^3.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mysql2": "^3.11.3",
|
||||
"nanostores": "^0.10.3",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@ -92,6 +92,12 @@ dependencies:
|
||||
'@remix-run/react':
|
||||
specifier: ^2.10.2
|
||||
version: 2.13.1(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3)
|
||||
'@types/crypto-js':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
'@types/lodash':
|
||||
specifier: ^4.17.12
|
||||
version: 4.17.12
|
||||
'@uiw/codemirror-theme-vscode':
|
||||
specifier: ^4.23.0
|
||||
version: 4.23.5(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)
|
||||
@ -119,6 +125,9 @@ dependencies:
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
crypto-js:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
date-fns:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
@ -143,6 +152,9 @@ dependencies:
|
||||
knex:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(mysql2@3.11.3)
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
mysql2:
|
||||
specifier: ^3.11.3
|
||||
version: 3.11.3
|
||||
@ -181,7 +193,7 @@ dependencies:
|
||||
version: 0.2.0(@remix-run/react@2.13.1)(@remix-run/server-runtime@2.13.1)(react-dom@18.3.1)(react@18.3.1)
|
||||
remix-utils:
|
||||
specifier: ^7.6.0
|
||||
version: 7.7.0(@remix-run/cloudflare@2.13.1)(@remix-run/node@2.13.1)(@remix-run/react@2.13.1)(react@18.3.1)(zod@3.23.8)
|
||||
version: 7.7.0(@remix-run/cloudflare@2.13.1)(@remix-run/node@2.13.1)(@remix-run/react@2.13.1)(crypto-js@4.2.0)(react@18.3.1)(zod@3.23.8)
|
||||
set-cookie-parser:
|
||||
specifier: 2.4.8
|
||||
version: 2.4.8
|
||||
@ -3126,6 +3138,7 @@ packages:
|
||||
resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3134,6 +3147,7 @@ packages:
|
||||
resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3142,6 +3156,7 @@ packages:
|
||||
resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3150,6 +3165,7 @@ packages:
|
||||
resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3158,6 +3174,7 @@ packages:
|
||||
resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3166,6 +3183,7 @@ packages:
|
||||
resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3174,6 +3192,7 @@ packages:
|
||||
resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3182,6 +3201,7 @@ packages:
|
||||
resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3190,6 +3210,7 @@ packages:
|
||||
resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
@ -3308,6 +3329,10 @@ packages:
|
||||
/@types/cookie@0.6.0:
|
||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||
|
||||
/@types/crypto-js@4.2.2:
|
||||
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
|
||||
dev: false
|
||||
|
||||
/@types/debug@4.1.12:
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
dependencies:
|
||||
@ -3351,6 +3376,10 @@ packages:
|
||||
'@types/node': 22.7.7
|
||||
dev: true
|
||||
|
||||
/@types/lodash@4.17.12:
|
||||
resolution: {integrity: sha512-sviUmCE8AYdaF/KIHLDJBQgeYzPBI0vf/17NaYehBJfYD1j6/L95Slh07NlyK2iNyBNaEkb3En2jRt+a8y3xZQ==}
|
||||
dev: false
|
||||
|
||||
/@types/mdast@3.0.15:
|
||||
resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==}
|
||||
dependencies:
|
||||
@ -4829,6 +4858,10 @@ packages:
|
||||
randomfill: 1.0.4
|
||||
dev: true
|
||||
|
||||
/crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
dev: false
|
||||
|
||||
/css-tree@2.3.1:
|
||||
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
@ -8999,7 +9032,7 @@ packages:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/remix-utils@7.7.0(@remix-run/cloudflare@2.13.1)(@remix-run/node@2.13.1)(@remix-run/react@2.13.1)(react@18.3.1)(zod@3.23.8):
|
||||
/remix-utils@7.7.0(@remix-run/cloudflare@2.13.1)(@remix-run/node@2.13.1)(@remix-run/react@2.13.1)(crypto-js@4.2.0)(react@18.3.1)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-J8NhP044nrNIam/xOT1L9a4RQ9FSaA2wyrUwmN8ZT+c/+CdAAf70yfaLnvMyKcV5U+8BcURQ/aVbth77sT6jGA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
@ -9035,6 +9068,7 @@ packages:
|
||||
'@remix-run/cloudflare': 2.13.1(@cloudflare/workers-types@4.20241018.0)(typescript@5.6.3)
|
||||
'@remix-run/node': 2.13.1(typescript@5.6.3)
|
||||
'@remix-run/react': 2.13.1(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3)
|
||||
crypto-js: 4.2.0
|
||||
react: 18.3.1
|
||||
type-fest: 4.26.1
|
||||
zod: 3.23.8
|
||||
|
||||
Loading…
Reference in New Issue
Block a user