feat: 增加充值功能

This commit is contained in:
archer
2023-03-21 23:14:28 +08:00
parent 129f3a2a30
commit d065539707
24 changed files with 389 additions and 35 deletions

View File

@@ -28,6 +28,7 @@
"immer": "^9.0.19",
"jsonwebtoken": "^9.0.0",
"mongoose": "^6.10.0",
"nanoid": "^4.0.1",
"next": "13.1.6",
"nodemailer": "^6.9.1",
"nprogress": "^0.2.0",
@@ -43,7 +44,6 @@
"sass": "^1.58.3",
"sharp": "^0.31.3",
"tunnel": "^0.0.6",
"uuid": "^9.0.0",
"zustand": "^4.3.5"
},
"devDependencies": {

19
pnpm-lock.yaml generated
View File

@@ -32,6 +32,7 @@ specifiers:
jsonwebtoken: ^9.0.0
lint-staged: ^13.1.2
mongoose: ^6.10.0
nanoid: ^4.0.1
next: 13.1.6
nodemailer: ^6.9.1
nprogress: ^0.2.0
@@ -49,7 +50,6 @@ specifiers:
sharp: ^0.31.3
tunnel: ^0.0.6
typescript: 4.9.5
uuid: ^9.0.0
zustand: ^4.3.5
dependencies:
@@ -70,6 +70,7 @@ dependencies:
immer: registry.npmmirror.com/immer/9.0.19
jsonwebtoken: registry.npmmirror.com/jsonwebtoken/9.0.0
mongoose: registry.npmmirror.com/mongoose/6.10.0
nanoid: registry.npmmirror.com/nanoid/4.0.1
next: registry.npmmirror.com/next/13.1.6_wiv434v7erz4aedd5whhdwmpv4
nodemailer: registry.npmmirror.com/nodemailer/6.9.1
nprogress: registry.npmmirror.com/nprogress/0.2.0
@@ -85,7 +86,6 @@ dependencies:
sass: registry.npmmirror.com/sass/1.58.3
sharp: registry.npmmirror.com/sharp/0.31.3
tunnel: registry.npmmirror.com/tunnel/0.0.6
uuid: registry.npmmirror.com/uuid/9.0.0
zustand: registry.npmmirror.com/zustand/4.3.5_immer@9.0.19+react@18.2.0
devDependencies:
@@ -8496,6 +8496,14 @@ packages:
hasBin: true
dev: false
registry.npmmirror.com/nanoid/4.0.1:
resolution: {integrity: sha512-udKGtCCUafD3nQtJg9wBhRP3KMbPglUsgV5JVsXhvyBs/oefqb4sqMEhKBBgqZncYowu58p1prsZQBYvAj/Gww==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/nanoid/-/nanoid-4.0.1.tgz}
name: nanoid
version: 4.0.1
engines: {node: ^14 || ^16 || >=18}
hasBin: true
dev: false
registry.npmmirror.com/napi-build-utils/1.0.2:
resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz}
name: napi-build-utils
@@ -10335,13 +10343,6 @@ packages:
dev: false
optional: true
registry.npmmirror.com/uuid/9.0.0:
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/uuid/-/uuid-9.0.0.tgz}
name: uuid
version: 9.0.0
hasBin: true
dev: false
registry.npmmirror.com/uvu/0.5.6:
resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/uvu/-/uvu-0.5.6.tgz}
name: uvu

BIN
public/imgs/wxcode.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

1
public/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -55,3 +55,12 @@ export const getUserBills = (data: RequestPaging) =>
...res,
data: res.data.map((bill) => adaptBill(bill))
}));
export const getPayCode = (amount: number) =>
GET<{
codeUrl: string;
orderId: string;
}>(`/user/getPayCode?amount=${amount}`);
export const checkPayResult = (orderId: string) =>
GET<number>(`/user/checkPayResult?orderId=${orderId}`);

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1679410564438" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2824" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32"><path d="M693.095316 281.760857l-131.632817 223.935003 103.718481 0 0 49.478312-120.846571 0 0 68.193688 120.846571 0 0 50.115659-120.846571 0 0 99.276514-62.164435 0L482.169975 673.483519 356.88022 673.483519l0-50.115659 125.289755 0 0-68.193688L356.88022 555.174172l0-49.478312 106.893053 0-130.364204-223.935003 70.099647 0c60.895822 111.230417 97.898433 181.748475 111.012698 211.562689l1.268612 0c4.441967-12.262847 16.596562-37.002611 36.474732-74.219292l74.536749-137.343396L693.095316 281.760857 693.095316 281.760857zM693.095316 281.760857" p-id="2825"></path><path d="M784.470674 621.448522c-15.061578 0-27.247797 12.187435-27.247797 27.247797s12.187435 27.247797 27.247797 27.247797l71.98128 0c-61.204765 128.843816-192.338895 217.986027-344.464118 217.986027-210.6687 0-381.478892-170.782216-381.478892-381.475243 0-210.696675 170.810191-381.465512 381.478892-381.465512 192.121175 0 350.635679 142.189179 377.137878 326.968701l55.08064 0C917.333181 242.953241 734.255278 76.493794 511.987837 76.493794 271.197197 76.493794 76.012135 271.688586 76.012135 512.456117c0 240.762665 195.185062 435.972053 435.975702 435.972053 164.236031 0 307.128238-90.894915 381.475243-225.064956l0 61.57574c0 15.061578 12.187435 27.247797 27.276989 27.247797 15.004412 0 27.247797-12.187435 27.247797-27.247797L947.987865 648.697535c0-3.297419 0-27.247797-27.247797-27.247797L784.470674 621.449738 784.470674 621.448522zM784.470674 621.448522" p-id="2826"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -7,7 +7,8 @@ const map = {
model: require('./icons/model.svg').default,
share: require('./icons/share.svg').default,
home: require('./icons/home.svg').default,
menu: require('./icons/menu.svg').default
menu: require('./icons/menu.svg').default,
pay: require('./icons/pay.svg').default
};
export type IconName = keyof typeof map;

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useRouter } from 'next/router';
import { useToast } from '@chakra-ui/react';
import { getTokenLogin } from '@/api/user';
import { useUserStore } from '@/store/user';
import { useGlobalStore } from '@/store/global';
import { useQuery } from '@tanstack/react-query';
@@ -19,7 +18,7 @@ const Auth = ({ children }: { children: JSX.Element }) => {
position: 'top',
status: 'warning'
});
const { userInfo, setUserInfo } = useUserStore();
const { userInfo, initUserInfo } = useUserStore();
const { setLoading } = useGlobalStore();
useQuery(
@@ -29,15 +28,10 @@ const Auth = ({ children }: { children: JSX.Element }) => {
return setLoading(false);
} else {
setLoading(true);
return getTokenLogin();
return initUserInfo();
}
},
{
onSuccess(user) {
if (user) {
setUserInfo(user);
}
},
onError(error) {
console.log('error->', error);
router.push('/login');

View File

@@ -24,7 +24,7 @@ export const ModelList: ModelConstantsData[] = [
trainName: 'turbo',
maxToken: 4000,
maxTemperature: 2,
price: 2
price: 5
},
{
serviceCompany: 'openai',
@@ -33,7 +33,7 @@ export const ModelList: ModelConstantsData[] = [
trainName: 'davinci',
maxToken: 4000,
maxTemperature: 2,
price: 20
price: 50
}
];

View File

@@ -39,6 +39,7 @@ export default function App({ Component, pageProps }: AppProps) {
<link rel="icon" href="/favicon.ico" />
</Head>
<Script src="/iconfont.js" strategy="afterInteractive"></Script>
<Script src="/qrcode.min.js" strategy="afterInteractive"></Script>
<QueryClientProvider client={queryClient}>
<ChakraProvider theme={theme}>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />

View File

@@ -0,0 +1,63 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import axios from 'axios';
import { connectToDatabase, User, Pay } from '@/service/mongo';
import { authToken } from '@/service/utils/tools';
import { formatPrice } from '@/utils/user';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
let { orderId } = req.query as { orderId: string };
const userId = await authToken(authorization);
const { data } = await axios.get(
`https://sif268.laf.dev/wechat-order-query?order_number=${orderId}&api_key=${process.env.WXPAYCODE}`
);
if (data.trade_state === 'SUCCESS') {
await connectToDatabase();
// 重复记录校验
const count = await Pay.count({
orderId
});
if (count > 0) {
throw new Error('订单重复,请刷新');
}
// 计算实际充值。把分转成数据库的值
const price = data.amount.total * 0.01 * 100000;
let payId;
try {
// 充值记录 +1
const payRecord = await Pay.create({
userId,
price,
orderId
});
payId = payRecord._id;
// 充钱
await User.findByIdAndUpdate(userId, {
$inc: { balance: price }
});
} catch (error) {
payId && Pay.findByIdAndDelete(payId);
}
jsonRes(res, {
data: 'success'
});
} else {
throw new Error(data.trade_state_desc);
}
} catch (err) {
console.log(err);
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -0,0 +1,45 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import axios from 'axios';
import { authToken } from '@/service/utils/tools';
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 20);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
let { amount = 0 } = req.query as { amount: string };
amount = +amount;
if (!authorization) {
throw new Error('缺少登录凭证');
}
await authToken(authorization);
const id = nanoid();
const response = await axios({
url: 'https://sif268.laf.dev/wechat-pay',
method: 'POST',
data: {
trade_order_number: id,
amount: amount * 100,
api_key: process.env.WXPAYCODE
}
});
jsonRes(res, {
data: {
orderId: id,
codeUrl: response.data?.code_url
}
});
} catch (err) {
console.log(err);
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -32,6 +32,7 @@ import { useCopyData } from '@/utils/tools';
import Markdown from '@/components/Markdown';
import { shareHint } from '@/constants/common';
import { getChatSiteId } from '@/api/chat';
import Image from 'next/image';
const SlideBar = ({
name,
@@ -53,6 +54,7 @@ const SlideBar = ({
const { chatHistory, removeChatHistoryByWindowId } = useChatStore();
const [hasReady, setHasReady] = useState(false);
const { isOpen: isOpenShare, onOpen: onOpenShare, onClose: onCloseShare } = useDisclosure();
const { isOpen: isOpenWx, onOpen: onOpenWx, onClose: onCloseWx } = useDisclosure();
const { isSuccess } = useQuery(['init'], getMyModels, {
cacheTime: 5 * 60 * 1000
@@ -113,7 +115,13 @@ const SlideBar = ({
</>
);
const RenderButton = ({ onClick, children }: { onClick: () => void; children: JSX.Element }) => (
const RenderButton = ({
onClick,
children
}: {
onClick: () => void;
children: JSX.Element | string;
}) => (
<Box px={3} mb={3}>
<Flex
alignItems={'center'}
@@ -229,8 +237,17 @@ const SlideBar = ({
</>
</RenderButton>
<RenderButton onClick={() => router.push('/number/setting')}>
<>
<MyIcon name="pay" fill={'white'} w={'16px'} h={'16px'} mr={4} />
</>
</RenderButton>
<Box textAlign={'end'} mr={4}>
<Flex alignItems={'center'} mr={4}>
<Box flex={1}>
<RenderButton onClick={onOpenWx}></RenderButton>
</Box>
<IconButton
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
aria-label={''}
@@ -242,7 +259,7 @@ const SlideBar = ({
}}
onClick={toggleColorMode}
/>
</Box>
</Flex>
{/* 分享提示modal */}
<Modal isOpen={isOpenShare} onClose={onCloseShare}>
@@ -287,6 +304,35 @@ const SlideBar = ({
</ModalFooter>
</ModalContent>
</Modal>
{/* wx 联系 */}
<Modal isOpen={isOpenWx} onClose={onCloseWx}>
<ModalOverlay />
<ModalContent color={useColorModeValue('blackAlpha.700', 'white')}>
<ModalHeader>wx交流群</ModalHeader>
<ModalCloseButton />
<ModalBody textAlign={'center'}>
<Image
style={{ margin: 'auto' }}
src={'/imgs/wxcode.jpg'}
width={200}
height={200}
alt=""
/>
<Box mt={2}>
:{' '}
<Box as={'span'} userSelect={'all'}>
YNyiqi
</Box>
</Box>
</ModalBody>
<ModalFooter>
<Button variant={'outline'} onClick={onCloseWx}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Flex>
);
};

View File

@@ -1,4 +1,4 @@
import React, { Dispatch, useState, useCallback } from 'react';
import React, { Dispatch, useState, useCallback, useMemo } from 'react';
import {
Modal,
ModalOverlay,
@@ -12,12 +12,14 @@ import {
Button,
useToast,
Input,
Select
Select,
Box
} from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { postCreateModel } from '@/api/model';
import type { ModelSchema } from '@/types/mongoSchema';
import { ModelList } from '@/constants/model';
import { formatPrice } from '@/utils/user';
interface CreateFormType {
name: string;
@@ -37,6 +39,7 @@ const CreateModel = ({
position: 'top'
});
const {
getValues,
register,
handleSubmit,
formState: { errors }
@@ -105,6 +108,12 @@ const CreateModel = ({
{!!errors.serviceModelName && errors.serviceModelName.message}
</FormErrorMessage>
</FormControl>
<Box mt={3} textAlign={'center'} fontSize={'sm'} color={'blackAlpha.600'}>
{formatPrice(
ModelList.find((item) => item.model === getValues('serviceModelName'))?.price || 0
) * 1000}
/1000()
</Box>
</ModalBody>
<ModalFooter>

View File

@@ -0,0 +1,138 @@
import React, { useState, useCallback } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
Input,
Box,
Grid
} from '@chakra-ui/react';
import { getPayCode, checkPayResult } from '@/api/user';
import { useToast } from '@/hooks/useToast';
import { useQuery } from '@tanstack/react-query';
import { useUserStore } from '@/store/user';
const PayModal = ({ onClose }: { onClose: () => void }) => {
const { toast } = useToast();
const { initUserInfo } = useUserStore();
const [inputVal, setInputVal] = useState<number | ''>('');
const [loading, setLoading] = useState(false);
const [orderId, setOrderId] = useState('');
const handleClickPay = useCallback(async () => {
if (!inputVal || inputVal <= 0 || isNaN(+inputVal)) return;
setLoading(true);
try {
// 获取支付二维码
const res = await getPayCode(inputVal);
new QRCode(document.getElementById('payQRCode'), {
text: res.codeUrl,
width: 128,
height: 128,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.H
});
setOrderId(res.orderId);
} catch (error) {
toast({
title: '出现了一些意外...',
status: 'error'
});
console.log(error);
}
setLoading(false);
}, [inputVal, toast]);
useQuery(
[orderId],
() => {
if (!orderId) return null;
return checkPayResult(orderId);
},
{
refetchInterval: 2000,
onSuccess(res) {
if (!res) return;
onClose();
initUserInfo();
toast({
title: '充值成功',
status: 'success'
});
}
}
);
return (
<>
<Modal
isOpen={true}
onClose={() => {
if (orderId) return;
onClose();
}}
>
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
{!orderId && <ModalCloseButton />}
<ModalBody>
{!orderId && (
<>
<Grid gridTemplateColumns={'repeat(4,1fr)'} gridGap={5} mb={4}>
{[5, 10, 20, 50].map((item) => (
<Button
key={item}
variant={item === inputVal ? 'solid' : 'outline'}
onClick={() => setInputVal(item)}
>
{item}
</Button>
))}
</Grid>
<Box>
<Input
value={inputVal}
type={'number'}
step={1}
placeholder={'其他金额,请取整数'}
onChange={(e) => {
setInputVal(Math.floor(+e.target.value));
}}
></Input>
</Box>
</>
)}
{/* 付费二维码 */}
<Box textAlign={'center'}>
{orderId && <Box mb={3}>: {inputVal}</Box>}
<Box id={'payQRCode'} display={'inline-block'}></Box>
</Box>
</ModalBody>
<ModalFooter>
{!orderId && (
<>
<Button colorScheme={'gray'} onClick={onClose}>
</Button>
<Button ml={3} isLoading={loading} onClick={handleClickPay}>
</Button>
</>
)}
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default PayModal;

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import {
Card,
Box,
@@ -25,13 +25,18 @@ import { useUserStore } from '@/store/user';
import { UserType } from '@/types/user';
import { usePaging } from '@/hooks/usePaging';
import type { UserBillType } from '@/types/user';
import { useQuery } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
const PayModal = dynamic(() => import('./components/PayModal'));
const NumberSetting = () => {
const { userInfo, updateUserInfo } = useUserStore();
const { userInfo, updateUserInfo, initUserInfo } = useUserStore();
const { setLoading } = useGlobalStore();
const { register, handleSubmit, control } = useForm<UserUpdateParams>({
defaultValues: userInfo as UserType
});
const [showPay, setShowPay] = useState(false);
const { toast } = useToast();
const {
fields: accounts,
@@ -43,9 +48,9 @@ const NumberSetting = () => {
});
const { setPageNum, data: bills } = usePaging<UserBillType>({
api: getUserBills,
pageSize: 20
pageSize: 30
});
console.log(bills);
const onclickSave = useCallback(
async (data: UserUpdateParams) => {
setLoading(true);
@@ -62,6 +67,8 @@ const NumberSetting = () => {
[setLoading, toast, updateUserInfo]
);
useQuery(['init'], initUserInfo);
return (
<>
<Card px={6} py={4}>
@@ -80,9 +87,9 @@ const NumberSetting = () => {
<Box>
<strong>{userInfo?.balance}</strong>
</Box>
{/* <Button size={'sm'} w={'80px'} ml={5}>
<Button size={'sm'} w={'80px'} ml={5} onClick={() => setShowPay(true)}>
</Button> */}
</Button>
</Flex>
</Box>
</Card>
@@ -181,6 +188,7 @@ const NumberSetting = () => {
</Table>
</TableContainer>
</Card>
{showPay && <PayModal onClose={() => setShowPay(false)} />}
</>
);
};

View File

@@ -36,6 +36,6 @@ export const pushBill = async ({
$inc: { balance: -price }
});
} catch (error) {
Bill.findByIdAndDelete(billId);
billId && Bill.findByIdAndDelete(billId);
}
};

23
src/service/models/pay.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Schema, model, models } from 'mongoose';
const PaySchema = new Schema({
userId: {
type: Schema.Types.ObjectId,
ref: 'user',
required: true
},
time: {
type: Number,
default: () => Date.now()
},
price: {
type: Number,
required: true
},
orderId: {
type: String,
required: true
}
});
export const Pay = models['pay'] || model('pay', PaySchema);

View File

@@ -16,7 +16,7 @@ const UserSchema = new Schema({
},
balance: {
type: Number,
default: 0
default: 0.5
},
accounts: [
{

View File

@@ -31,3 +31,4 @@ export * from './models/model';
export * from './models/user';
export * from './models/training';
export * from './models/bill';
export * from './models/pay';

View File

@@ -22,8 +22,12 @@ export const generateToken = (userId: string) => {
};
/* 校验 token */
export const authToken = (token: string): Promise<string> => {
export const authToken = (token?: string): Promise<string> => {
return new Promise((resolve, reject) => {
if (!token) {
reject('缺少登录凭证');
return;
}
const key = process.env.TOKEN_KEY as string;
jwt.verify(token, key, function (err, decoded: any) {

View File

@@ -6,9 +6,11 @@ import type { ModelSchema } from '@/types/mongoSchema';
import { setToken } from '@/utils/user';
import { getMyModels } from '@/api/model';
import { formatPrice } from '@/utils/user';
import { getTokenLogin } from '@/api/user';
type State = {
userInfo: UserType | null;
initUserInfo: () => Promise<null>;
setUserInfo: (user: UserType, token?: string) => void;
updateUserInfo: (user: UserUpdateParams) => void;
myModels: ModelSchema[];
@@ -20,6 +22,11 @@ export const useUserStore = create<State>()(
devtools(
immer((set, get) => ({
userInfo: null,
async initUserInfo() {
const res = await getTokenLogin();
get().setUserInfo(res);
return null;
},
setUserInfo(user: UserType, token?: string) {
set((state) => {
state.userInfo = {

View File

@@ -2,6 +2,7 @@ import type { Mongoose } from 'mongoose';
declare global {
var mongodb: Mongoose | string | null;
var QRCode: any;
}
export type PagingData<T> = {

1
src/types/user.d.ts vendored
View File

@@ -14,6 +14,7 @@ export interface UserType {
}
export interface UserUpdateParams {
balance?: number;
accounts?: {
type: string;
value: string;