feat: 好友邀请

This commit is contained in:
archer
2023-04-21 22:23:19 +08:00
parent 4f51839026
commit 4397a0ad6b
22 changed files with 471 additions and 17 deletions

View File

@@ -1,5 +1,13 @@
import type { UserType } from '@/types/user';
import type { PromotionRecordSchema } from '@/types/mongoSchema';
export interface ResLogin {
token: string;
user: UserType;
}
export interface PromotionRecordType {
_id: PromotionRecordSchema['_id'];
type: PromotionRecordSchema['type'];
createTime: PromotionRecordSchema['createTime'];
amount: PromotionRecordSchema['amount'];
}

View File

@@ -1,6 +1,6 @@
import { GET, POST, PUT } from './request';
import { createHashPassword, Obj2Query } from '@/utils/tools';
import { ResLogin } from './response/user';
import { ResLogin, PromotionRecordType } from './response/user';
import { UserAuthTypeEnum } from '@/constants/common';
import { UserType, UserUpdateParams } from '@/types/user';
import type { PagingData, RequestPaging } from '@/types';
@@ -17,6 +17,14 @@ export const sendAuthCode = ({
export const getTokenLogin = () => GET<UserType>('/user/tokenLogin');
/* get promotion init data */
export const getPromotionInitData = () =>
GET<{
invitedAmount: number;
historyAmount: number;
residueAmount: number;
}>('/user/promotion/getPromotionData');
export const postRegister = ({
username,
password,
@@ -73,3 +81,7 @@ export const getPayCode = (amount: number) =>
}>(`/user/getPayCode?amount=${amount}`);
export const checkPayResult = (payId: string) => GET<number>(`/user/checkPayResult?payId=${payId}`);
/* promotion records */
export const getPromotionRecords = (data: RequestPaging) =>
GET<PromotionRecordType>(`/user/promotion/getPromotions?${Obj2Query(data)}`);

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="1682078370900" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3577" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32"><path d="M941.312 888.704H628.032a32 32 0 0 1 0-64h313.28a32 32 0 0 1 0 64zM519.808 576.768c-158.976 0-288.384-129.344-288.384-288.384S360.832 0 519.808 0s288.384 129.344 288.384 288.384-129.408 288.384-288.384 288.384z m0-512.768C396.096 64 295.424 164.672 295.424 288.384s100.672 224.384 224.384 224.384c123.776 0 224.384-100.672 224.384-224.384S643.584 64 519.808 64z" p-id="3578"></path><path d="M763.264 606.528a31.552 31.552 0 0 1-16.96-4.864 427.2 427.2 0 0 0-100.544-45.952 32 32 0 0 1-21.184-40 31.744 31.744 0 0 1 39.936-21.184 492.16 492.16 0 0 1 115.712 52.864 32 32 0 0 1-16.96 59.136zM59.776 996.928a32 32 0 0 1-32-32 489.6 489.6 0 0 1 347.328-470.464 32 32 0 1 1 18.816 61.184 425.856 425.856 0 0 0-302.144 409.28 32 32 0 0 1-32 32zM964.224 879.68a32.128 32.128 0 0 1-24.32-11.2l-108.224-126.336a32 32 0 1 1 48.64-41.6l108.224 126.336a32 32 0 0 1-24.32 52.8z" p-id="3579"></path><path d="M856 1024a32 32 0 0 1-25.664-51.2l108.224-144.32a32.064 32.064 0 0 1 51.264 38.336L881.6 1011.2a32 32 0 0 1-25.6 12.8z" p-id="3580"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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="1682079057126" class="icon" viewBox="0 0 1322 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2677" xmlns:xlink="http://www.w3.org/1999/xlink" width="36.1484375" height="28"><path d="M952.04654459 837.88839531H336.95615443A113.52706888 113.52706888 0 0 1 223.61160468 724.54384556v-419.79462838h728.43493991a113.52706888 113.52706888 0 0 1 113.34454973 113.34454975V724.54384556a113.52706888 113.52706888 0 0 1-113.34454973 113.34454975zM278.36742569 359.13999928v365.03880736a58.77124787 58.77124787 0 0 0 58.58872873 58.58872874h615.09039016a58.77124787 58.77124787 0 0 0 58.58872874-58.58872874V417.72872802a58.77124787 58.77124787 0 0 0-58.58872874-58.58872874z" p-id="2678"></path><path d="M278.36742569 350.37906772H223.61160468V297.44844068A111.51935577 111.51935577 0 0 1 334.94844068 186.11160469h334.01050924a111.51935577 111.51935577 0 0 1 111.33683598 111.33683599v49.09771996h-54.75582101V297.44844068A56.76353475 56.76353475 0 0 0 668.95894991 240.8674257H334.94844068A56.76353475 56.76353475 0 0 0 278.36742569 297.44844068zM1038.19570329 704.83175018H825.92563707A131.59649008 131.59649008 0 0 1 825.92563707 441.63877003h208.43715913v54.75582103H825.92563707a76.84066906 76.84066906 0 0 0 0 153.86385725h212.27006621z" p-id="2679"></path><path d="M889.80742792 600.43065117h-65.34194654a27.37791082 27.37791082 0 0 1 0-54.75582103h65.34194654a27.37791082 27.37791082 0 0 1-1e-8 54.75582103z" p-id="2680"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -14,7 +14,9 @@ const map = {
develop: require('./icons/develop.svg').default,
user: require('./icons/user.svg').default,
chatting: require('./icons/chatting.svg').default,
delete: require('./icons/delete.svg').default
promotion: require('./icons/promotion.svg').default,
delete: require('./icons/delete.svg').default,
withdraw: require('./icons/withdraw.svg').default
};
export type IconName = keyof typeof map;

View File

@@ -32,6 +32,12 @@ const navbarList = [
link: '/number/setting',
activeLink: ['/number/setting']
},
{
label: '邀请',
icon: 'promotion',
link: '/promotion',
activeLink: ['/promotion']
},
{
label: '开发',
icon: 'develop',

View File

@@ -20,3 +20,15 @@ export const BillTypeMap: Record<`${BillTypeEnum}`, string> = {
[BillTypeEnum.vector]: '索引生成',
[BillTypeEnum.return]: '退款'
};
export enum PromotionEnum {
invite = 'invite',
shareModel = 'shareModel',
withdraw = 'withdraw'
}
export const PromotionTypeMap = {
[PromotionEnum.invite]: '好友充值',
[PromotionEnum.shareModel]: '模型分享',
[PromotionEnum.withdraw]: '提现'
};

View File

@@ -18,7 +18,7 @@ export const usePagination = <T = any,>({
const [pageNum, setPageNum] = useState(1);
const [total, setTotal] = useState(0);
const [data, setData] = useState<T[]>([]);
const maxPage = useMemo(() => Math.ceil(total / pageSize), [pageSize, total]);
const maxPage = useMemo(() => Math.ceil(total / pageSize) || 1, [pageSize, total]);
const { mutate, isLoading } = useMutation({
mutationFn: async (num: number = pageNum) => {

View File

@@ -2,9 +2,11 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, User, Pay } from '@/service/mongo';
import { authToken } from '@/service/utils/tools';
import { PaySchema } from '@/types/mongoSchema';
import { PaySchema, UserModelSchema } from '@/types/mongoSchema';
import dayjs from 'dayjs';
import { getPayResult } from '@/service/utils/wxpay';
import { pushPromotionRecord } from '@/service/utils/promotion';
import { PRICE_SCALE } from '@/constants/common';
/* 校验支付结果 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -26,6 +28,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new Error('订单已结算');
}
// 获取当前用户
const user = await User.findById(userId);
if (!user) {
throw new Error('找不到用户');
}
// 获取邀请者
let inviter: UserModelSchema | null = null;
if (user.inviterId) {
inviter = await User.findById(user.inviterId);
}
const payRes = await getPayResult(payOrder.orderId);
// 校验下是否超过一天
@@ -50,6 +63,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await User.findByIdAndUpdate(userId, {
$inc: { balance: payOrder.price }
});
// 推广佣金发放
if (inviter) {
pushPromotionRecord({
userId: inviter._id,
objUId: userId,
type: 'invite',
// amount 单位为元,需要除以缩放比例,最后乘比例
amount: (payOrder.price / PRICE_SCALE) * inviter.promotion.rate * 0.01
});
}
jsonRes(res, {
data: '支付成功'
});

View File

@@ -15,7 +15,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await connectToDatabase();
const records = await Pay.find({
userId
userId,
status: { $ne: 'CLOSED' }
}).sort({ createTime: -1 });
jsonRes(res, {

View File

@@ -0,0 +1,70 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, User, promotionRecord } from '@/service/mongo';
import { authToken } from '@/service/utils/tools';
import mongoose from 'mongoose';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
await connectToDatabase();
const invitedAmount = await User.countDocuments({
inviterId: userId
});
// 计算累计合
const countHistory: { totalAmount: number }[] = await promotionRecord.aggregate([
{ $match: { userId: new mongoose.Types.ObjectId(userId), amount: { $gt: 0 } } },
{
$group: {
_id: null, // 分组条件,这里使用 null 表示不分组
totalAmount: { $sum: '$amount' } // 计算 amount 字段的总和
}
},
{
$project: {
_id: false, // 排除 _id 字段
totalAmount: true // 只返回 totalAmount 字段
}
}
]);
// 计算剩余金额
const countResidue: { totalAmount: number }[] = await promotionRecord.aggregate([
{ $match: { userId: new mongoose.Types.ObjectId(userId) } },
{
$group: {
_id: null, // 分组条件,这里使用 null 表示不分组
totalAmount: { $sum: '$amount' } // 计算 amount 字段的总和
}
},
{
$project: {
_id: false, // 排除 _id 字段
totalAmount: true // 只返回 totalAmount 字段
}
}
]);
jsonRes(res, {
data: {
invitedAmount,
historyAmount: countHistory[0]?.totalAmount || 0,
residueAmount: countResidue[0]?.totalAmount || 0
}
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -0,0 +1,48 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, promotionRecord } from '@/service/mongo';
import { authToken } from '@/service/utils/tools';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { authorization } = req.headers;
let { pageNum = 1, pageSize = 10 } = req.query as { pageNum: string; pageSize: string };
pageNum = +pageNum;
pageSize = +pageSize;
if (!authorization) {
throw new Error('缺少登录凭证');
}
const userId = await authToken(authorization);
await connectToDatabase();
const data = await promotionRecord
.find(
{
userId
},
'_id createTime type amount'
)
.sort({ _id: -1 })
.skip((pageNum - 1) * pageSize)
.limit(pageSize);
jsonRes(res, {
data: {
pageNum,
pageSize,
data,
total: await promotionRecord.countDocuments({
userId
})
}
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -45,11 +45,12 @@ const BillTable = () => {
))}
</Tbody>
</Table>
<Box mt={4} mr={4} textAlign={'end'}>
<Pagination />
</Box>
<Loading loading={isLoading} fixed={false} />
</TableContainer>
<Box mt={4} mr={4} textAlign={'end'}>
<Pagination />
</Box>
</Card>
);
};

View File

@@ -7,7 +7,8 @@ import { useToast } from '@/hooks/useToast';
import { useGlobalStore } from '@/store/global';
import { useUserStore } from '@/store/user';
import { UserType } from '@/types/user';
import { clearToken } from '@/utils/user';
import { useRouter } from 'next/router';
import { useQuery } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
@@ -16,6 +17,7 @@ const BilTable = dynamic(() => import('./components/BillTable'));
const PayModal = dynamic(() => import('./components/PayModal'));
const NumberSetting = () => {
const router = useRouter();
const { userInfo, updateUserInfo, initUserInfo } = useUserStore();
const { setLoading } = useGlobalStore();
const { register, handleSubmit } = useForm<UserUpdateParams>({
@@ -43,13 +45,23 @@ const NumberSetting = () => {
useQuery(['init'], initUserInfo);
const onclickLogOut = useCallback(() => {
clearToken();
router.replace('/login');
}, [router]);
return (
<>
{/* 核心信息 */}
<Card px={6} py={4}>
<Box fontSize={'xl'} fontWeight={'bold'}>
</Box>
<Flex justifyContent={'space-between'}>
<Box fontSize={'xl'} fontWeight={'bold'}>
</Box>
<Button variant={'outline'} size={'xs'} onClick={onclickLogOut}>
退
</Button>
</Flex>
<Flex mt={6} alignItems={'center'}>
<Box flex={'0 0 60px'}>:</Box>
<Box>{userInfo?.username}</Box>

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import Link from 'next/link';
import {
Card,
Box,
Button,
Flex,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useColorModeValue,
ModalFooter,
useDisclosure
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useLoading } from '@/hooks/useLoading';
import dayjs from 'dayjs';
import { useCopyData } from '@/utils/tools';
import { useUserStore } from '@/store/user';
import MyIcon from '@/components/Icon';
import { getPromotionRecords } from '@/api/user';
import { usePagination } from '@/hooks/usePagination';
import { PromotionRecordType } from '@/api/response/user';
import { PromotionTypeMap } from '@/constants/user';
import { getPromotionInitData } from '@/api/user';
import Image from 'next/image';
const OpenApi = () => {
const { Loading } = useLoading();
const { userInfo, initUserInfo } = useUserStore();
const { copyData } = useCopyData();
const {
isOpen: isOpenWithdraw,
onClose: onCloseWithdraw,
onOpen: onOpenWithdraw
} = useDisclosure();
useQuery(['init'], initUserInfo);
const { data: { invitedAmount = 0, historyAmount = 0, residueAmount = 0 } = {} } = useQuery(
['getInvitedCountAmount'],
getPromotionInitData
);
const {
data: promotionRecords,
isLoading,
Pagination,
total
} = usePagination<PromotionRecordType>({
api: getPromotionRecords
});
return (
<>
<Card px={6} py={4} position={'relative'}>
<Box fontSize={'xl'} fontWeight={'bold'}>
</Box>
<Box my={2} color={'blackAlpha.600'} fontSize={'sm'}>
FastGpt FastGpt
</Box>
<Flex my={2} alignItems={'center'}>
<Box>: </Box>
<Box mx={2} fontSize={'xl'} lineHeight={1} fontWeight={'bold'}>
{residueAmount}
</Box>
</Flex>
<Flex>
<Button
mr={4}
variant={'outline'}
onClick={() => {
copyData(`${location.origin}?inviterId=${userInfo?._id}`, '已复制邀请链接');
}}
>
</Button>
<Button
leftIcon={<MyIcon name="withdraw" w={'22px'} />}
px={4}
title={residueAmount < 50 ? '最低提现额度为50元' : ''}
isDisabled={residueAmount < 50}
onClick={onOpenWithdraw}
>
</Button>
</Flex>
</Card>
<Card mt={4} px={6} py={4} position={'relative'}>
<Flex alignItems={'center'} mb={3} justifyContent={['space-between', 'flex-start']}>
<Box w={'120px'}></Box>
<Box fontWeight={'bold'}>{userInfo?.promotion.rate || 15}%</Box>
</Flex>
<Flex alignItems={'center'} mb={3} justifyContent={['space-between', 'flex-start']}>
<Box w={'120px'}></Box>
<Box fontWeight={'bold'}>{invitedAmount}</Box>
</Flex>
<Flex alignItems={'center'} justifyContent={['space-between', 'flex-start']}>
<Box w={'120px'}></Box>
<Box fontWeight={'bold'}> {historyAmount}</Box>
</Flex>
</Card>
<Card mt={4} px={6} py={4} position={'relative'}>
<Box fontSize={'xl'} fontWeight={'bold'}>
({total})
</Box>
<TableContainer position={'relative'}>
<Table>
<Thead>
<Tr>
<Th></Th>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody fontSize={'sm'}>
{promotionRecords.map((item) => (
<Tr key={item._id}>
<Td>
{item.createTime ? dayjs(item.createTime).format('YYYY/MM/DD HH:mm:ss') : '-'}
</Td>
<Td>{PromotionTypeMap[item.type]}</Td>
<Td>{item.amount}</Td>
</Tr>
))}
</Tbody>
</Table>
<Loading loading={isLoading} fixed={false} />
</TableContainer>
<Box mt={4} mr={4} textAlign={'end'}>
<Pagination />
</Box>
</Card>
<Modal isOpen={isOpenWithdraw} onClose={onCloseWithdraw}>
<ModalOverlay />
<ModalContent color={useColorModeValue('blackAlpha.700', 'white')}>
<ModalHeader></ModalHeader>
<ModalCloseButton />
<ModalBody textAlign={'center'}>
<Image
style={{ margin: 'auto' }}
src={'/imgs/wx300-2.jpg'}
width={200}
height={200}
alt=""
/>
<Box mt={2}>
:
<Box as={'span'} userSelect={'all'}>
YNyiqi
</Box>
</Box>
<Box></Box>
</ModalBody>
<ModalFooter>
<Button variant={'outline'} onClick={onCloseWithdraw}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default OpenApi;

View File

@@ -0,0 +1,31 @@
import { Schema, model, models, Model } from 'mongoose';
import { PromotionRecordSchema as PromotionRecordType } from '@/types/mongoSchema';
const PromotionRecordSchema = new Schema({
userId: {
type: Schema.Types.ObjectId,
ref: 'user',
required: true
},
objUId: {
type: Schema.Types.ObjectId,
ref: 'user',
required: false
},
createTime: {
type: Date,
default: () => new Date()
},
type: {
type: String,
required: true,
enum: ['invite', 'shareModel', 'withdraw']
},
amount: {
type: Number,
required: true
}
});
export const promotionRecord: Model<PromotionRecordType> =
models['promotionRecord'] || model('promotionRecord', PromotionRecordSchema);

View File

@@ -31,11 +31,6 @@ const UserSchema = new Schema({
// 返现比例
type: Number,
default: 15
},
amount: {
// 推广金额
type: Number,
default: 0
}
},
openaiKey: {

View File

@@ -62,3 +62,4 @@ export * from './models/data';
export * from './models/dataItem';
export * from './models/splitData';
export * from './models/openapi';
export * from './models/promotionRecord';

View File

@@ -0,0 +1,36 @@
import { promotionRecord } from '../mongo';
export const pushPromotionRecord = async ({
userId,
objUId,
type,
amount
}: {
userId: string;
objUId: string;
type: 'invite' | 'shareModel';
amount: number;
}) => {
try {
await promotionRecord.create({
userId,
objUId,
type,
amount
});
} catch (error) {
console.log('创建推广记录异常', error);
}
};
export const withdrawRecord = async ({ userId, amount }: { userId: string; amount: number }) => {
try {
await promotionRecord.create({
userId,
type: 'withdraw',
amount
});
} catch (error) {
console.log('提现记录异常', error);
}
};

View File

@@ -18,6 +18,9 @@ export interface UserModelSchema {
promotionAmount: number;
openaiKey: string;
createTime: number;
promotion: {
rate: number;
};
}
export interface AuthCodeSchema {
@@ -162,3 +165,12 @@ export interface OpenApiSchema {
lastUsedTime?: Date;
apiKey: String;
}
export interface PromotionRecordSchema {
_id: string;
userId: string; // 收益人
objUId?: string; // 目标对象如果是withdraw则为空
type: 'invite' | 'shareModel' | 'withdraw';
createTime: Date; // 记录时间
amount: number;
}

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

@@ -3,6 +3,9 @@ export interface UserType {
username: string;
openaiKey: string;
balance: number;
promotion: {
rate: number;
};
}
export interface UserUpdateParams {