wechat publish (#6607)

* wechat publish

* update test

* doc
This commit is contained in:
Archer
2026-03-23 11:57:05 +08:00
committed by GitHub
parent c84c45398a
commit c37b3aa0e8
56 changed files with 2303 additions and 139 deletions
@@ -0,0 +1,226 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { Box, Button, Flex, ModalBody, ModalFooter, Spinner, Text } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import { useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { POST, GET } from '@/web/common/api/request';
import QRCode from 'qrcode';
import MyLoading from '@fastgpt/web/components/common/MyLoading';
import { formatFileSize } from '@fastgpt/global/common/file/tools';
type QRStatus = 'loading' | 'wait' | 'scanned' | 'confirmed' | 'expired' | 'error';
const QRLoginModal = ({
shareId,
onSuccess,
onClose
}: {
shareId: string;
onSuccess: () => void;
onClose: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const [status, setStatus] = useState<QRStatus>('loading');
const [qrText, setQrText] = useState('');
const [errMsg, setErrMsg] = useState('');
const canvasRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
const pollingRef = useRef(false);
const stopPolling = useCallback(() => {
pollingRef.current = false;
}, []);
// 串行轮询:等上一个请求完成后再发起下一个
const startPolling = useCallback(() => {
pollingRef.current = true;
const poll = async () => {
while (pollingRef.current && mountedRef.current) {
try {
const data = await GET<{ status: string }>('/support/outLink/wechat/qrcode/status', {
shareId
});
if (!mountedRef.current || !pollingRef.current) return;
switch (data.status) {
case 'scaned':
setStatus('scanned');
break;
case 'confirmed':
setStatus('confirmed');
pollingRef.current = false;
toast({
title: t('publish:wechat.login_success'),
status: 'success'
});
setTimeout(onSuccess, 1000);
return;
case 'expired':
setStatus('expired');
pollingRef.current = false;
return;
}
} catch {
if (!mountedRef.current) return;
setStatus('error');
setErrMsg(t('publish:wechat.status_check_failed'));
pollingRef.current = false;
return;
}
// 等待 2s 后再发起下一轮
await new Promise((r) => setTimeout(r, 2000));
}
};
poll();
}, [shareId, toast, t, onSuccess]);
// 用 qrcode 库渲染二维码到 canvas
const drawQRCode = useCallback((text: string) => {
if (!text || !canvasRef.current) return;
const canvas = document.createElement('canvas');
QRCode.toCanvas(canvas, text, {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' }
})
.then(() => {
if (canvasRef.current) {
canvasRef.current.innerHTML = '';
canvasRef.current.appendChild(canvas);
}
})
.catch(console.error);
}, []);
const generateQR = useCallback(async () => {
try {
setStatus('loading');
setErrMsg('');
stopPolling();
const data = await POST<{
qrcode: string;
qrcode_img_content: string;
}>('/support/outLink/wechat/qrcode/generate', { shareId });
if (!mountedRef.current) return;
setQrText(data.qrcode_img_content);
setStatus('wait');
startPolling();
} catch {
if (!mountedRef.current) return;
setStatus('error');
setErrMsg(t('publish:wechat.qr_generate_failed'));
}
}, [shareId, startPolling, stopPolling, t]);
// qrText 变化时重新渲染二维码
useEffect(() => {
drawQRCode(qrText);
}, [qrText, drawQRCode]);
useEffect(() => {
mountedRef.current = true;
generateQR();
return () => {
mountedRef.current = false;
stopPolling();
};
}, []);
const renderContent = () => {
switch (status) {
case 'loading':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<MyLoading fixed={false} text={t('publish:wechat.generating_qr')} />
</Flex>
);
case 'wait':
return (
<Flex direction="column" align="center">
<Box
p={4}
bg="white"
borderRadius="lg"
boxShadow="md"
border="1px solid"
borderColor="gray.200"
>
<Box ref={canvasRef} w="220px" h="220px" display="inline-block" />
</Box>
<Text mt={4} fontSize="lg" fontWeight="medium">
{t('publish:wechat.scan_qr_tip')}
</Text>
<Text mt={1} fontSize="sm" color="gray.500">
{t('publish:wechat.scan_qr_desc')}
</Text>
</Flex>
);
case 'scanned':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px">👀</Text>
<Text mt={4} fontSize="lg" fontWeight="medium" color="blue.600">
{t('publish:wechat.scanned_tip')}
</Text>
<Text mt={1} fontSize="sm" color="gray.500">
{t('publish:wechat.scanned_desc')}
</Text>
</Flex>
);
case 'confirmed':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text>
<Text mt={4} fontSize="lg" fontWeight="medium" color="green.600">
{t('publish:wechat.confirmed_tip')}
</Text>
</Flex>
);
case 'expired':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text>
<Text mt={4} fontSize="lg" fontWeight="medium" color="orange.600">
{t('publish:wechat.expired_tip')}
</Text>
<Button mt={4} colorScheme="blue" onClick={generateQR}>
{t('publish:wechat.retry')}
</Button>
</Flex>
);
case 'error':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text>
<Text mt={4} color="red.500">
{errMsg}
</Text>
<Button mt={4} colorScheme="blue" onClick={generateQR}>
{t('publish:wechat.retry')}
</Button>
</Flex>
);
}
};
return (
<MyModal isOpen onClose={onClose} title={t('publish:wechat.login_title')} size="md">
<ModalBody py={6}>{renderContent()}</ModalBody>
<ModalFooter>
<Button variant="whiteBase" onClick={onClose}>
{t('common:Close')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default QRLoginModal;
@@ -0,0 +1,108 @@
import React from 'react';
import { Box, Button, Flex, Grid, Input, ModalBody, ModalFooter } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import type { WechatAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type';
import { useTranslation } from 'next-i18next';
import { useForm } from 'react-hook-form';
import { createShareChat, updateShareChat } from '@/web/support/outLink/api';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
const WechatEditModal = ({
appId,
defaultData,
onClose,
onCreate,
onEdit,
isEdit = false
}: {
appId: string;
defaultData: OutLinkEditType<WechatAppType>;
onClose: () => void;
onCreate: (shareId: string) => Promise<string | undefined>;
onEdit: () => void;
isEdit?: boolean;
}) => {
const { t } = useTranslation();
const { register, setValue, handleSubmit } = useForm({
defaultValues: defaultData
});
const { runAsync: onclickCreate, loading: creating } = useRequest(
(e) =>
createShareChat({
...e,
appId,
type: PublishChannelEnum.wechat
}),
{
errorToast: t('common:create_failed'),
successToast: t('common:create_success'),
onSuccess: async (shareId) => {
const _id = await onCreate(shareId);
if (_id) setValue('_id', _id);
onClose();
}
}
);
const { runAsync: onclickUpdate, loading: updating } = useRequest((e) => updateShareChat(e), {
errorToast: t('common:update_failed'),
successToast: t('common:update_success'),
onSuccess: () => {
onEdit();
onClose();
}
});
return (
<MyModal
iconSrc="core/app/publish/wechat"
title={isEdit ? t('publish:wechat.edit') : t('publish:wechat.create')}
minW={['auto', '500px']}
onClose={onClose}
>
<ModalBody fontSize={'14px'} p={8}>
<Grid gridTemplateColumns={'1fr'} gap={4}>
<Flex flexDir={'column'} gap={2}>
<FormLabel required>{t('common:Name')}</FormLabel>
<Input
placeholder={t('publish:wechat.name_placeholder')}
maxLength={100}
{...register('name', { required: t('common:name_is_empty') })}
/>
</Flex>
<Flex flexDir={'column'} gap={2}>
<FormLabel>
{t('common:support.outlink.Max usage points')}
<QuestionTip ml={1} label={t('common:support.outlink.Max usage points tip')} />
</FormLabel>
<Input
{...register('limit.maxUsagePoints', {
min: -1,
max: 10000000,
valueAsNumber: true
})}
/>
</Flex>
</Grid>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
{t('common:Close')}
</Button>
<Button
isLoading={creating || updating}
onClick={handleSubmit((data) => (isEdit ? onclickUpdate(data) : onclickCreate(data)))}
>
{t('common:Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default WechatEditModal;
@@ -0,0 +1,251 @@
import React, { useState } from 'react';
import {
Box,
Button,
Flex,
Link,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr
} from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useLoading } from '@fastgpt/web/hooks/useLoading';
import { getShareChatList, delShareChatById } from '@/web/support/outLink/api';
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
import { defaultOutLinkForm } from '@/web/core/app/constants';
import type { WechatAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import { useTranslation } from 'next-i18next';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import dynamic from 'next/dynamic';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { getDocPath } from '@/web/common/system/doc';
import { POST } from '@/web/common/api/request';
import type { ColorSchemaType } from '@fastgpt/web/components/common/Tag/index';
import MyTag from '@fastgpt/web/components/common/Tag/index';
const WechatEditModal = dynamic(() => import('./WechatEditModal'));
const QRLoginModal = dynamic(() => import('./QRLoginModal'));
const Wechat = ({ appId }: { appId: string }) => {
const { t } = useTranslation();
const { Loading, setIsLoading } = useLoading();
const { feConfigs } = useSystemStore();
const [editData, setEditData] = useState<OutLinkEditType<WechatAppType>>();
const [isEdit, setIsEdit] = useState(false);
const [loginShareId, setLoginShareId] = useState<string>();
const {
data: shareChatList = [],
loading: isFetching,
runAsync: refetch
} = useRequest(
() => getShareChatList<WechatAppType>({ appId, type: PublishChannelEnum.wechat }),
{
manual: false,
refreshDeps: [appId]
}
);
const statusBadge = (status?: string) => {
const map: Record<string, { colorSchema: ColorSchemaType; label: string }> = {
online: { colorSchema: 'green', label: t('publish:wechat.status.online') },
offline: { colorSchema: 'gray', label: t('publish:wechat.status.offline') },
error: { colorSchema: 'red', label: t('publish:wechat.status.error') }
};
const cfg = map[status || 'offline'] ?? map['offline'];
return <MyTag colorSchema={cfg.colorSchema}>{cfg.label}</MyTag>;
};
return (
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
<Flex justifyContent={'space-between'}>
<Flex alignItems={'center'}>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
{t('publish:wechat.title')}
</Box>
{feConfigs?.docUrl && (
<Link
href={getDocPath('/docs/use-cases/external-integration/wechat')}
target={'_blank'}
ml={2}
color={'primary.500'}
fontSize={'sm'}
>
<Flex alignItems={'center'}>
<MyIcon name="book" w={'17px'} h={'17px'} mr="1" />
{t('common:read_doc')}
</Flex>
</Link>
)}
</Flex>
<Button
variant={'primary'}
size={['sm', 'md']}
leftIcon={<MyIcon name={'common/addLight'} w="1.25rem" color="white" />}
{...(shareChatList.length >= 10
? { isDisabled: true, title: t('common:core.app.share.Amount limit tip') }
: {})}
onClick={() => {
setEditData(defaultOutLinkForm as any);
setIsEdit(false);
}}
>
{t('common:add_new')}
</Button>
</Flex>
<TableContainer mt={3}>
<Table variant={'simple'} w={'100%'} fontSize={'sm'}>
<Thead>
<Tr>
<Th>{t('common:Name')}</Th>
<Th>{t('publish:wechat.status')}</Th>
<Th>{t('common:support.outlink.Usage points')}</Th>
<Th>{t('common:last_use_time')}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{shareChatList.map((item) => (
<Tr key={item._id}>
<Td>{item.name}</Td>
<Td>{statusBadge(item.app?.status)}</Td>
<Td>{Math.round(item.usagePoints)}</Td>
<Td>
{item.lastTime
? t(formatTimeToChatTime(item.lastTime) as any).replace('#', ':')
: t('common:un_used')}
</Td>
<Td display={'flex'} alignItems={'center'}>
{!item.app?.token ? (
<Button
size={'sm'}
mr={3}
colorScheme="green"
onClick={() => {
setLoginShareId(item.shareId);
}}
>
{t('publish:wechat.login')}
</Button>
) : item.app.status === 'online' ? (
<Button
size={'sm'}
mr={3}
variant={'whiteBase'}
onClick={async () => {
setIsLoading(true);
try {
await POST('/support/outLink/wechat/logout', {
shareId: item.shareId
});
refetch();
} finally {
setIsLoading(false);
}
}}
>
{t('publish:wechat.logout')}
</Button>
) : (
<Button
size={'sm'}
mr={3}
variant={'whitePrimary'}
onClick={() => {
setLoginShareId(item.shareId);
}}
>
{t('publish:wechat.relogin')}
</Button>
)}
<MyMenu
Button={
<Button size={'smSquare'} variant={'whiteBase'}>
<MyIcon name={'more'} w={'14px'} />
</Button>
}
menuList={[
{
children: [
{
label: t('common:Edit'),
icon: 'edit',
onClick: () => {
setEditData({
_id: item._id,
name: item.name,
limit: item.limit,
app: item.app,
defaultResponse: item.defaultResponse,
immediateResponse: item.immediateResponse
});
setIsEdit(true);
}
},
{
label: t('common:Delete'),
icon: 'delete',
onClick: async () => {
setIsLoading(true);
try {
await delShareChatById(item._id);
refetch();
} finally {
setIsLoading(false);
}
}
}
]
}
]}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
{shareChatList.length === 0 && !isFetching && (
<EmptyTip text={t('common:core.app.share.Not share link')} />
)}
{editData && (
<WechatEditModal
appId={appId}
defaultData={editData}
isEdit={isEdit}
onCreate={async (shareId) => {
const newList = await refetch();
return newList?.find((i) => i.shareId === shareId)?._id;
}}
onEdit={() => refetch()}
onClose={() => setEditData(undefined)}
/>
)}
{loginShareId && (
<QRLoginModal
shareId={loginShareId}
onSuccess={() => {
refetch();
setLoginShareId(undefined);
}}
onClose={() => setLoginShareId(undefined)}
/>
)}
<Loading loading={isFetching} fixed={false} />
</Box>
);
};
export default React.memo(Wechat);
@@ -20,6 +20,7 @@ const FeiShu = dynamic(() => import('./FeiShu'));
const DingTalk = dynamic(() => import('./DingTalk'));
const Wecom = dynamic(() => import('./Wecom'));
const OffiAccount = dynamic(() => import('./OffiAccount'));
const Wechat = dynamic(() => import('./Wechat'));
const Playground = dynamic(() => import('./Playground'));
const OutLink = () => {
@@ -45,6 +46,13 @@ const OutLink = () => {
value: PublishChannelEnum.apikey,
isProFn: false
},
{
icon: 'core/app/publish/wechat',
title: t('publish:wechat.bot'),
desc: t('publish:wechat.bot_desc'),
value: PublishChannelEnum.wechat,
isProFn: true
},
...(feConfigs?.show_publish_feishu !== false &&
!userInfo?.tags?.includes(UserTagsEnum.enum.wecom)
? [
@@ -91,6 +99,7 @@ const OutLink = () => {
}
]
: []),
{
icon: 'core/chat/sidebar/home',
title: t('common:navbar.Chat'),
@@ -145,6 +154,7 @@ const OutLink = () => {
{linkType === PublishChannelEnum.dingtalk && <DingTalk appId={appId} />}
{linkType === PublishChannelEnum.wecom && <Wecom appId={appId} />}
{linkType === PublishChannelEnum.officialAccount && <OffiAccount appId={appId} />}
{linkType === PublishChannelEnum.wechat && <Wechat appId={appId} />}
{linkType === PublishChannelEnum.playground && <Playground appId={appId} />}
</Flex>
</Box>
@@ -10,7 +10,7 @@ import { type ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type';
import { type AuthModeType } from '@fastgpt/service/support/permission/type';
import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant';
import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink';
import { authOutLinkInit } from '@/service/support/permission/auth/outLink';
import { authOutLinkInit } from '@fastgpt/service/support/outLink/runtime/auth';
import { authTeamSpaceToken } from '@/service/support/permission/auth/team';
import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema';
import { TeamMemberRoleEnum } from '@fastgpt/global/support/user/team/constant';
@@ -0,0 +1,24 @@
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink';
import type { WechatAppType } from '@fastgpt/global/support/outLink/type';
async function handler(req: ApiRequestProps<{ shareId: string }>): Promise<void> {
const { shareId } = req.body;
await authOutLinkValid<WechatAppType>({ shareId });
await MongoOutLink.updateOne(
{ shareId },
{
$set: {
'app.status': 'offline',
'app.token': '',
'app.lastError': ''
}
}
);
}
export default NextAPI(handler);
@@ -0,0 +1,27 @@
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { ILinkClient } from '@fastgpt/service/support/outLink/wechat/ilinkClient';
import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink';
import type { WechatAppType } from '@fastgpt/global/support/outLink/type';
import { setRedisCache } from '@fastgpt/service/common/redis/cache';
async function handler(
req: ApiRequestProps<{ shareId: string }>
): Promise<{ qrcode: string; qrcode_img_content: string; expireTime: number }> {
const { shareId } = req.body;
await authOutLinkValid<WechatAppType>({ shareId });
const client = new ILinkClient();
const qrData = await client.getQRCode();
await setRedisCache(`publish:wechat:qrcode:${shareId}`, JSON.stringify(qrData), 480);
return {
qrcode: qrData.qrcode,
qrcode_img_content: qrData.qrcode_img_content,
expireTime: 480
};
}
export default NextAPI(handler);
@@ -0,0 +1,44 @@
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { ILinkClient } from '@fastgpt/service/support/outLink/wechat/ilinkClient';
import { getRedisCache, delRedisCache } from '@fastgpt/service/common/redis/cache';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { startWechatPolling } from '@fastgpt/service/support/outLink/wechat/mq';
async function handler(req: ApiRequestProps<{}, { shareId: string }>): Promise<{ status: string }> {
const { shareId } = req.query;
const raw = await getRedisCache(`publish:wechat:qrcode:${shareId}`);
if (!raw) {
return { status: 'expired' };
}
const qrData = JSON.parse(raw);
const client = new ILinkClient();
const statusData = await client.getQRCodeStatus(qrData.qrcode);
if (statusData.status === 'confirmed' && statusData.bot_token && statusData.ilink_bot_id) {
await MongoOutLink.updateOne(
{ shareId },
{
$set: {
'app.token': statusData.bot_token,
'app.baseUrl': statusData.baseurl || 'https://ilinkai.weixin.qq.com',
'app.accountId': statusData.ilink_bot_id,
'app.userId': statusData.ilink_user_id || '',
'app.status': 'online',
'app.loginTime': new Date().toISOString(),
'app.syncBuf': '',
'app.lastError': ''
}
}
);
await delRedisCache(`publish:wechat:qrcode:${shareId}`);
await startWechatPolling(shareId);
}
return { status: statusData.status };
}
export default NextAPI(handler);
@@ -4,6 +4,7 @@ import { initDatasetDeleteWorker } from '@fastgpt/service/core/dataset/delete';
import { initAppDeleteWorker } from '@fastgpt/service/core/app/delete';
import { initTeamDeleteWorker } from '@fastgpt/service/support/user/team/delete';
import { initCollectionUpdateWorker } from '@fastgpt/service/core/dataset/collection/mq';
import { initWechatPollWorker } from '@fastgpt/service/support/outLink/wechat/mq';
const logger = getLogger(LogCategories.INFRA.QUEUE);
@@ -14,6 +15,7 @@ export const initBullMQWorkers = () => {
initDatasetDeleteWorker(),
initAppDeleteWorker(),
initTeamDeleteWorker(),
initCollectionUpdateWorker()
initCollectionUpdateWorker(),
initWechatPollWorker()
]);
};
@@ -2,7 +2,6 @@ import { POST } from '@fastgpt/service/common/api/plusRequest';
import type {
AuthOutLinkChatProps,
AuthOutLinkLimitProps,
AuthOutLinkInitProps,
AuthOutLinkResponse
} from '@fastgpt/global/support/outLink/api';
import { type ShareChatAuthProps } from '@fastgpt/global/support/permission/chat';
@@ -10,11 +9,8 @@ import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/au
import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant';
import { OutLinkErrEnum } from '@fastgpt/global/common/error/code/outLink';
import { type OutLinkSchema } from '@fastgpt/global/support/outLink/type';
import { authOutLinkInit } from '@fastgpt/service/support/outLink/runtime/auth';
export function authOutLinkInit(data: AuthOutLinkInitProps): Promise<AuthOutLinkResponse> {
if (!global.feConfigs?.isPlus) return Promise.resolve({ uid: data.outLinkUid });
return POST<AuthOutLinkResponse>('/support/outLink/authInit', data);
}
export function authOutLinkChatLimit(data: AuthOutLinkLimitProps): Promise<AuthOutLinkResponse> {
if (!global.feConfigs?.isPlus) return Promise.resolve({ uid: data.outLinkUid });
return POST<AuthOutLinkResponse>('/support/outLink/authChatStart', data);