Commit 81d39199 by Archer Committed by GitHub

fix: secure wechat outlink binding (#7260)

parent 1862abfa
...@@ -158,3 +158,66 @@ export const OutLinkDeleteResponseSchema = z.undefined().meta({ ...@@ -158,3 +158,66 @@ export const OutLinkDeleteResponseSchema = z.undefined().meta({
description: '删除成功' description: '删除成功'
}); });
export type OutLinkDeleteResponseType = z.infer<typeof OutLinkDeleteResponseSchema>; export type OutLinkDeleteResponseType = z.infer<typeof OutLinkDeleteResponseSchema>;
const WechatOutLinkIdSchema = ObjectIdSchema.meta({
description: '微信发布渠道 ID'
});
/* ============================================================================
* API: 生成微信发布渠道登录二维码
* Route: POST /api/support/outLink/wechat/qrcode/generate
* Method: POST
* Description: 为当前团队有管理权限的微信发布渠道生成 iLink 登录二维码。
* Tags: ['发布渠道', '微信发布渠道']
* ============================================================================ */
export const WechatQrcodeGenerateBodySchema = z.object({
outLinkId: WechatOutLinkIdSchema
});
export type WechatQrcodeGenerateBodyType = z.infer<typeof WechatQrcodeGenerateBodySchema>;
export const WechatQrcodeGenerateResponseSchema = z.object({
qrcode: z.string().meta({ description: 'iLink 二维码标识' }),
qrcode_img_content: z.string().meta({ description: '二维码内容' }),
expireTime: z.number().meta({ example: 480, description: '二维码有效期,单位秒' })
});
export type WechatQrcodeGenerateResponseType = z.infer<typeof WechatQrcodeGenerateResponseSchema>;
/* ============================================================================
* API: 查询微信发布渠道登录二维码状态
* Route: GET /api/support/outLink/wechat/qrcode/status
* Method: GET
* Description: 查询当前登录成员发起的微信发布渠道二维码登录状态,确认后写入机器人凭据。
* Tags: ['发布渠道', '微信发布渠道']
* ============================================================================ */
export const WechatQrcodeStatusQuerySchema = z.object({
outLinkId: WechatOutLinkIdSchema
});
export type WechatQrcodeStatusQueryType = z.infer<typeof WechatQrcodeStatusQuerySchema>;
export const WechatQrcodeStatusResponseSchema = z.object({
status: z.enum(['wait', 'scaned', 'confirmed', 'expired']).meta({
example: 'wait',
description: '二维码登录状态'
})
});
export type WechatQrcodeStatusResponseType = z.infer<typeof WechatQrcodeStatusResponseSchema>;
/* ============================================================================
* API: 登出微信发布渠道
* Route: POST /api/support/outLink/wechat/logout
* Method: POST
* Description: 将当前团队有管理权限的微信发布渠道下线并清空机器人凭据。
* Tags: ['发布渠道', '微信发布渠道']
* ============================================================================ */
export const WechatLogoutBodySchema = z.object({
outLinkId: WechatOutLinkIdSchema
});
export type WechatLogoutBodyType = z.infer<typeof WechatLogoutBodySchema>;
export const WechatLogoutResponseSchema = z.undefined().meta({
description: '登出成功'
});
export type WechatLogoutResponseType = z.infer<typeof WechatLogoutResponseSchema>;
...@@ -8,7 +8,13 @@ import { ...@@ -8,7 +8,13 @@ import {
OutLinkListQuerySchema, OutLinkListQuerySchema,
OutLinkListResponseSchema, OutLinkListResponseSchema,
OutLinkUpdateBodySchema, OutLinkUpdateBodySchema,
OutLinkUpdateResponseSchema OutLinkUpdateResponseSchema,
WechatLogoutBodySchema,
WechatLogoutResponseSchema,
WechatQrcodeGenerateBodySchema,
WechatQrcodeGenerateResponseSchema,
WechatQrcodeStatusQuerySchema,
WechatQrcodeStatusResponseSchema
} from './api'; } from './api';
export const OutLinkPath: OpenAPIPath = { export const OutLinkPath: OpenAPIPath = {
...@@ -99,5 +105,73 @@ export const OutLinkPath: OpenAPIPath = { ...@@ -99,5 +105,73 @@ export const OutLinkPath: OpenAPIPath = {
} }
} }
} }
},
'/support/outLink/wechat/qrcode/generate': {
post: {
summary: '生成微信发布渠道登录二维码',
description: '为当前团队有管理权限的微信发布渠道生成 iLink 登录二维码',
tags: [DevApiTagsMap.publishChannel],
requestBody: {
content: {
'application/json': {
schema: WechatQrcodeGenerateBodySchema
}
}
},
responses: {
200: {
description: '成功生成二维码',
content: {
'application/json': {
schema: WechatQrcodeGenerateResponseSchema
}
}
}
}
}
},
'/support/outLink/wechat/qrcode/status': {
get: {
summary: '查询微信发布渠道登录二维码状态',
description: '查询当前登录成员发起的微信发布渠道二维码登录状态',
tags: [DevApiTagsMap.publishChannel],
requestParams: {
query: WechatQrcodeStatusQuerySchema
},
responses: {
200: {
description: '成功返回二维码状态',
content: {
'application/json': {
schema: WechatQrcodeStatusResponseSchema
}
}
}
}
}
},
'/support/outLink/wechat/logout': {
post: {
summary: '登出微信发布渠道',
description: '将当前团队有管理权限的微信发布渠道下线并清空机器人凭据',
tags: [DevApiTagsMap.publishChannel],
requestBody: {
content: {
'application/json': {
schema: WechatLogoutBodySchema
}
}
},
responses: {
200: {
description: '成功登出微信发布渠道',
content: {
'application/json': {
schema: WechatLogoutResponseSchema
}
}
}
}
}
} }
}; };
...@@ -6,6 +6,27 @@ const BOT_TYPE = '3'; ...@@ -6,6 +6,27 @@ const BOT_TYPE = '3';
const LONG_POLL_TIMEOUT_MS = 35_000; const LONG_POLL_TIMEOUT_MS = 35_000;
const SEND_TIMEOUT_MS = 15_000; const SEND_TIMEOUT_MS = 15_000;
const formatFetchError = (err: unknown) => {
if (!(err instanceof Error)) return String(err);
const cause = err.cause as
| {
code?: string;
message?: string;
name?: string;
}
| undefined;
return [
`${err.name}: ${err.message}`,
cause?.code ? `causeCode=${cause.code}` : '',
cause?.name ? `causeName=${cause.name}` : '',
cause?.message ? `causeMessage=${cause.message}` : ''
]
.filter(Boolean)
.join('; ');
};
export type WeixinMessage = { export type WeixinMessage = {
msgid: string; msgid: string;
from_user_id: string; from_user_id: string;
...@@ -59,15 +80,12 @@ export class ILinkClient { ...@@ -59,15 +80,12 @@ export class ILinkClient {
return Buffer.from(String(uint32), 'utf-8').toString('base64'); return Buffer.from(String(uint32), 'utf-8').toString('base64');
} }
private buildHeaders(body?: string): Record<string, string> { private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
AuthorizationType: 'ilink_bot_token', AuthorizationType: 'ilink_bot_token',
'X-WECHAT-UIN': this.randomUin() 'X-WECHAT-UIN': this.randomUin()
}; };
if (body) {
headers['Content-Length'] = String(Buffer.byteLength(body, 'utf-8'));
}
if (this.token) { if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`; headers['Authorization'] = `Bearer ${this.token}`;
} }
...@@ -82,7 +100,7 @@ export class ILinkClient { ...@@ -82,7 +100,7 @@ export class ILinkClient {
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: this.buildHeaders(body), headers: this.buildHeaders(),
body, body,
signal: controller.signal signal: controller.signal
}); });
...@@ -92,7 +110,7 @@ export class ILinkClient { ...@@ -92,7 +110,7 @@ export class ILinkClient {
return text; return text;
} catch (err) { } catch (err) {
clearTimeout(timer); clearTimeout(timer);
throw err; throw new Error(`iLink POST ${endpoint} failed: ${formatFetchError(err)}`);
} }
} }
......
...@@ -54,6 +54,7 @@ async function processWechatPollJob(job: Job<WechatPollJobData>): Promise<void> ...@@ -54,6 +54,7 @@ async function processWechatPollJob(job: Job<WechatPollJobData>): Promise<void>
async function pollImpl(job: Job<WechatPollJobData>): Promise<void> { async function pollImpl(job: Job<WechatPollJobData>): Promise<void> {
const { shareId } = job.data; const { shareId } = job.data;
logger.debug('Wechat poll job started', { shareId, jobId: job.id });
const outLink = (await MongoOutLink.findOne({ const outLink = (await MongoOutLink.findOne({
shareId shareId
...@@ -75,7 +76,21 @@ async function pollImpl(job: Job<WechatPollJobData>): Promise<void> { ...@@ -75,7 +76,21 @@ async function pollImpl(job: Job<WechatPollJobData>): Promise<void> {
} }
const client = new ILinkClient(app.baseUrl, app.token); const client = new ILinkClient(app.baseUrl, app.token);
const resp = await client.getUpdates(app.syncBuf || ''); const resp = await client.getUpdates(app.syncBuf || '').catch((error) => {
logger.error('Wechat getUpdates request failed', {
shareId,
baseUrl: app.baseUrl,
error: String(error)
});
throw error;
});
logger.debug('Wechat getUpdates returned', {
shareId,
msgCount: resp.msgs?.length ?? 0,
hasNextBuf: Boolean(resp.get_updates_buf),
ret: resp.ret,
errcode: resp.errcode
});
const isError = const isError =
(resp.ret !== undefined && resp.ret !== 0) || (resp.ret !== undefined && resp.ret !== 0) ||
...@@ -194,7 +209,7 @@ async function processWechatReplyJob(job: Job<WechatReplyJobData>): Promise<void ...@@ -194,7 +209,7 @@ async function processWechatReplyJob(job: Job<WechatReplyJobData>): Promise<void
// - 如果链已死(无 job),add 正常入队 // - 如果链已死(无 job),add 正常入队
async function scheduleNextPoll(shareId: string, delayMs?: number): Promise<void> { async function scheduleNextPoll(shareId: string, delayMs?: number): Promise<void> {
const queue = getQueue<WechatPollJobData>(QueueNames.wechatPoll); const queue = getQueue<WechatPollJobData>(QueueNames.wechatPoll);
await queue.add( const job = await queue.add(
POLL_JOB_NAME, POLL_JOB_NAME,
{ shareId }, { shareId },
{ {
...@@ -204,6 +219,12 @@ async function scheduleNextPoll(shareId: string, delayMs?: number): Promise<void ...@@ -204,6 +219,12 @@ async function scheduleNextPoll(shareId: string, delayMs?: number): Promise<void
removeOnFail: true removeOnFail: true
} }
); );
logger.debug('Wechat poll job scheduled', {
shareId,
jobId: job.id,
delayMs,
jobState: await job.getState().catch(() => undefined)
});
} }
/** /**
...@@ -253,6 +274,11 @@ export const initWechatPollWorker = async () => { ...@@ -253,6 +274,11 @@ export const initWechatPollWorker = async () => {
pollWorker.on('failed', async (job) => { pollWorker.on('failed', async (job) => {
if (!job || job.name !== POLL_JOB_NAME) return; if (!job || job.name !== POLL_JOB_NAME) return;
const { shareId } = job.data as WechatPollJobData; const { shareId } = job.data as WechatPollJobData;
logger.warn('Wechat poll job failed', {
shareId,
jobId: job.id,
failedReason: job.failedReason
});
try { try {
await retryFn(async () => { await retryFn(async () => {
if (!(await shouldContinuePolling(shareId))) return; if (!(await shouldContinuePolling(shareId))) return;
...@@ -303,6 +329,15 @@ async function resumeAllWechatPolling(): Promise<void> { ...@@ -303,6 +329,15 @@ async function resumeAllWechatPolling(): Promise<void> {
* 启动某个渠道的轮询 * 启动某个渠道的轮询
*/ */
export const startWechatPolling = async (shareId: string): Promise<void> => { export const startWechatPolling = async (shareId: string): Promise<void> => {
const queue = getQueue<WechatPollJobData>(QueueNames.wechatPoll);
// 重新登录后优先丢弃旧的 offline/delayed poll job,避免确定 jobId 被旧任务占用。
await queue.remove(pollJobId(shareId)).catch((error) => {
logger.warn('Remove old wechat poll job before start failed (job may be active)', {
shareId,
error: String(error)
});
});
await scheduleNextPoll(shareId); await scheduleNextPoll(shareId);
logger.info('Wechat polling started', { shareId }); logger.info('Wechat polling started', { shareId });
}; };
......
/**
* 生成微信 iLink 二维码登录缓存 key。
*
* outLinkId 绑定具体发布渠道,tmbId 绑定发起操作的团队成员,确保状态确认只能消费
* 当前登录成员自己创建的二维码。
*/
export const getWechatQrcodeCacheKey = ({
outLinkId,
tmbId
}: {
outLinkId: string;
tmbId: string;
}) => `publish:wechat:qrcode:${outLinkId}:${tmbId}`;
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import { OutLinkErrEnum } from '@fastgpt/global/common/error/code/outLink';
import { type OutLinkSchemaType } from '@fastgpt/global/support/outLink/type';
/**
* 确保后台微信接口只能操作微信发布渠道。
*
* authOutLinkCrud 负责登录、teamId 和应用权限校验;这里补齐微信接口自己的渠道类型边界,
* 避免同团队成员把其他发布渠道 ID 传入微信登录/登出接口后写入微信配置字段。
*/
export const assertWechatOutLink = (outLink: Pick<OutLinkSchemaType, 'type'>) => {
if (outLink.type !== PublishChannelEnum.wechat) {
return Promise.reject(OutLinkErrEnum.linkUnInvalid);
}
};
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback } from 'react';
import { Box, Button, Flex, ModalBody, ModalFooter, Spinner, Text } from '@chakra-ui/react'; import { Box, Button, Flex, ModalBody, ModalFooter, Text } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/v2/common/MyModal'; import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { POST, GET } from '@/web/common/api/request'; import { POST, GET } from '@/web/common/api/request';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import MyLoading from '@fastgpt/web/components/common/MyLoading'; import MyLoading from '@fastgpt/web/components/common/MyLoading';
import { formatFileSize } from '@fastgpt/global/common/file/tools';
type QRStatus = 'loading' | 'wait' | 'scanned' | 'confirmed' | 'expired' | 'error'; type QRStatus = 'loading' | 'wait' | 'scanned' | 'confirmed' | 'expired' | 'error';
const QRLoginModal = ({ const QRLoginModal = ({
shareId, outLinkId,
onSuccess, onSuccess,
onClose onClose
}: { }: {
shareId: string; outLinkId: string;
onSuccess: () => void; onSuccess: () => void;
onClose: () => void; onClose: () => void;
}) => { }) => {
...@@ -40,7 +39,7 @@ const QRLoginModal = ({ ...@@ -40,7 +39,7 @@ const QRLoginModal = ({
while (pollingRef.current && mountedRef.current) { while (pollingRef.current && mountedRef.current) {
try { try {
const data = await GET<{ status: string }>('/support/outLink/wechat/qrcode/status', { const data = await GET<{ status: string }>('/support/outLink/wechat/qrcode/status', {
shareId outLinkId
}); });
if (!mountedRef.current || !pollingRef.current) return; if (!mountedRef.current || !pollingRef.current) return;
...@@ -76,7 +75,7 @@ const QRLoginModal = ({ ...@@ -76,7 +75,7 @@ const QRLoginModal = ({
}; };
poll(); poll();
}, [shareId, toast, t, onSuccess]); }, [outLinkId, toast, t, onSuccess]);
// 用 qrcode 库渲染二维码到 canvas // 用 qrcode 库渲染二维码到 canvas
const drawQRCode = useCallback((text: string) => { const drawQRCode = useCallback((text: string) => {
...@@ -105,7 +104,7 @@ const QRLoginModal = ({ ...@@ -105,7 +104,7 @@ const QRLoginModal = ({
const data = await POST<{ const data = await POST<{
qrcode: string; qrcode: string;
qrcode_img_content: string; qrcode_img_content: string;
}>('/support/outLink/wechat/qrcode/generate', { shareId }); }>('/support/outLink/wechat/qrcode/generate', { outLinkId });
if (!mountedRef.current) return; if (!mountedRef.current) return;
...@@ -118,7 +117,7 @@ const QRLoginModal = ({ ...@@ -118,7 +117,7 @@ const QRLoginModal = ({
setStatus('error'); setStatus('error');
setErrMsg(t('publish:wechat.qr_generate_failed')); setErrMsg(t('publish:wechat.qr_generate_failed'));
} }
}, [shareId, startPolling, stopPolling, t]); }, [outLinkId, startPolling, stopPolling, t]);
// qrText 变化时重新渲染二维码 // qrText 变化时重新渲染二维码
useEffect(() => { useEffect(() => {
...@@ -127,12 +126,14 @@ const QRLoginModal = ({ ...@@ -127,12 +126,14 @@ const QRLoginModal = ({
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
// eslint-disable-next-line react-hooks/set-state-in-effect
generateQR(); generateQR();
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
stopPolling(); stopPolling();
}; };
}, []); }, [generateQR, stopPolling]);
const renderContent = () => { const renderContent = () => {
switch (status) { switch (status) {
......
...@@ -39,7 +39,7 @@ const Wechat = ({ appId }: { appId: string }) => { ...@@ -39,7 +39,7 @@ const Wechat = ({ appId }: { appId: string }) => {
const { feConfigs } = useSystemStore(); const { feConfigs } = useSystemStore();
const [editData, setEditData] = useState<OutLinkEditType<WechatAppType>>(); const [editData, setEditData] = useState<OutLinkEditType<WechatAppType>>();
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const [loginShareId, setLoginShareId] = useState<string>(); const [loginOutLinkId, setLoginOutLinkId] = useState<string>();
const { const {
data: shareChatList = [], data: shareChatList = [],
...@@ -130,7 +130,7 @@ const Wechat = ({ appId }: { appId: string }) => { ...@@ -130,7 +130,7 @@ const Wechat = ({ appId }: { appId: string }) => {
mr={3} mr={3}
colorScheme="green" colorScheme="green"
onClick={() => { onClick={() => {
setLoginShareId(item.shareId); setLoginOutLinkId(item._id);
}} }}
> >
{t('publish:wechat.login')} {t('publish:wechat.login')}
...@@ -144,7 +144,7 @@ const Wechat = ({ appId }: { appId: string }) => { ...@@ -144,7 +144,7 @@ const Wechat = ({ appId }: { appId: string }) => {
setIsLoading(true); setIsLoading(true);
try { try {
await POST('/support/outLink/wechat/logout', { await POST('/support/outLink/wechat/logout', {
shareId: item.shareId outLinkId: item._id
}); });
refetch(); refetch();
} finally { } finally {
...@@ -160,7 +160,7 @@ const Wechat = ({ appId }: { appId: string }) => { ...@@ -160,7 +160,7 @@ const Wechat = ({ appId }: { appId: string }) => {
mr={3} mr={3}
variant={'whitePrimary'} variant={'whitePrimary'}
onClick={() => { onClick={() => {
setLoginShareId(item.shareId); setLoginOutLinkId(item._id);
}} }}
> >
{t('publish:wechat.relogin')} {t('publish:wechat.relogin')}
...@@ -232,14 +232,14 @@ const Wechat = ({ appId }: { appId: string }) => { ...@@ -232,14 +232,14 @@ const Wechat = ({ appId }: { appId: string }) => {
/> />
)} )}
{loginShareId && ( {loginOutLinkId && (
<QRLoginModal <QRLoginModal
shareId={loginShareId} outLinkId={loginOutLinkId}
onSuccess={() => { onSuccess={() => {
refetch(); refetch();
setLoginShareId(undefined); setLoginOutLinkId(undefined);
}} }}
onClose={() => setLoginShareId(undefined)} onClose={() => setLoginOutLinkId(undefined)}
/> />
)} )}
......
import type { ApiRequestProps } from '@fastgpt/service/type/next'; import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry'; import { NextAPI } from '@/service/middleware/entry';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema'; import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink'; import { authOutLinkCrud } from '@fastgpt/service/support/permission/publish/authLink';
import type { WechatAppType } from '@fastgpt/global/support/outLink/type'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
WechatLogoutBodySchema,
WechatLogoutResponseSchema,
type WechatLogoutBodyType,
type WechatLogoutResponseType
} from '@fastgpt/global/openapi/support/outLink/api';
import { ManagePermissionVal } from '@fastgpt/global/support/permission/constant';
import { assertWechatOutLink } from '@fastgpt/service/support/outLink/wechat/utils';
async function handler(req: ApiRequestProps<{ shareId: string }>): Promise<void> { async function handler(
const { shareId } = req.body; req: ApiRequestProps<WechatLogoutBodyType>
): Promise<WechatLogoutResponseType> {
const { outLinkId } = parseApiInput({
req,
bodySchema: WechatLogoutBodySchema
}).body;
await authOutLinkValid<WechatAppType>({ shareId }); const { outLink } = await authOutLinkCrud({
req,
authToken: true,
outLinkId,
per: ManagePermissionVal
});
await assertWechatOutLink(outLink);
await MongoOutLink.updateOne( await MongoOutLink.updateOne(
{ shareId }, { _id: outLink._id },
{ {
$set: { $set: {
'app.status': 'offline', 'app.status': 'offline',
...@@ -19,6 +38,8 @@ async function handler(req: ApiRequestProps<{ shareId: string }>): Promise<void> ...@@ -19,6 +38,8 @@ async function handler(req: ApiRequestProps<{ shareId: string }>): Promise<void>
} }
} }
); );
return WechatLogoutResponseSchema.parse(undefined);
} }
export default NextAPI(handler); export default NextAPI(handler);
import type { ApiRequestProps } from '@fastgpt/service/type/next'; import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry'; import { NextAPI } from '@/service/middleware/entry';
import { ILinkClient } from '@fastgpt/service/support/outLink/wechat/ilinkClient'; import { ILinkClient } from '@fastgpt/service/support/outLink/wechat/ilinkClient';
import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink'; import { authOutLinkCrud } from '@fastgpt/service/support/permission/publish/authLink';
import type { WechatAppType } from '@fastgpt/global/support/outLink/type';
import { setRedisCache } from '@fastgpt/service/common/redis/cache'; import { setRedisCache } from '@fastgpt/service/common/redis/cache';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
WechatQrcodeGenerateBodySchema,
WechatQrcodeGenerateResponseSchema,
type WechatQrcodeGenerateBodyType,
type WechatQrcodeGenerateResponseType
} from '@fastgpt/global/openapi/support/outLink/api';
import { ManagePermissionVal } from '@fastgpt/global/support/permission/constant';
import { getWechatQrcodeCacheKey } from '@fastgpt/service/support/outLink/wechat/qrcode';
import { assertWechatOutLink } from '@fastgpt/service/support/outLink/wechat/utils';
const EXPIRE_TIME = 480;
async function handler( async function handler(
req: ApiRequestProps<{ shareId: string }> req: ApiRequestProps<WechatQrcodeGenerateBodyType>
): Promise<{ qrcode: string; qrcode_img_content: string; expireTime: number }> { ): Promise<WechatQrcodeGenerateResponseType> {
const { shareId } = req.body; const { outLinkId } = parseApiInput({
req,
bodySchema: WechatQrcodeGenerateBodySchema
}).body;
await authOutLinkValid<WechatAppType>({ shareId }); const { tmbId, outLink } = await authOutLinkCrud({
req,
authToken: true,
outLinkId,
per: ManagePermissionVal
});
await assertWechatOutLink(outLink);
const client = new ILinkClient(); const client = new ILinkClient();
const qrData = await client.getQRCode(); const qrData = await client.getQRCode();
await setRedisCache(`publish:wechat:qrcode:${shareId}`, JSON.stringify(qrData), 480); await setRedisCache(
getWechatQrcodeCacheKey({ outLinkId, tmbId }),
JSON.stringify(qrData),
EXPIRE_TIME
);
return { return WechatQrcodeGenerateResponseSchema.parse({
qrcode: qrData.qrcode, qrcode: qrData.qrcode,
qrcode_img_content: qrData.qrcode_img_content, qrcode_img_content: qrData.qrcode_img_content,
expireTime: 480 expireTime: EXPIRE_TIME
}; });
} }
export default NextAPI(handler); export default NextAPI(handler);
import type { ApiRequestProps } from '@fastgpt/service/type/next'; import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry'; import { NextAPI } from '@/service/middleware/entry';
import { ILinkClient } from '@fastgpt/service/support/outLink/wechat/ilinkClient'; import { ILinkClient } from '@fastgpt/service/support/outLink/wechat/ilinkClient';
import { getRedisCache, delRedisCache } from '@fastgpt/service/common/redis/cache'; import { getRedisCache, delRedisCache } from '@fastgpt/service/common/redis/cache';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema'; import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { startWechatPolling } from '@fastgpt/service/support/outLink/wechat/mq'; import { startWechatPolling } from '@fastgpt/service/support/outLink/wechat/mq';
import { authOutLinkCrud } from '@fastgpt/service/support/permission/publish/authLink';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
WechatQrcodeStatusQuerySchema,
WechatQrcodeStatusResponseSchema,
type WechatQrcodeStatusQueryType,
type WechatQrcodeStatusResponseType
} from '@fastgpt/global/openapi/support/outLink/api';
import { ManagePermissionVal } from '@fastgpt/global/support/permission/constant';
import { getWechatQrcodeCacheKey } from '@fastgpt/service/support/outLink/wechat/qrcode';
import { assertWechatOutLink } from '@fastgpt/service/support/outLink/wechat/utils';
async function handler(req: ApiRequestProps<{}, { shareId: string }>): Promise<{ status: string }> { async function handler(
const { shareId } = req.query; req: ApiRequestProps<Record<string, never>, WechatQrcodeStatusQueryType>,
res: ApiResponseType
): Promise<WechatQrcodeStatusResponseType> {
res.setHeader('Cache-Control', 'no-store');
const raw = await getRedisCache(`publish:wechat:qrcode:${shareId}`); const { outLinkId } = parseApiInput({
req,
querySchema: WechatQrcodeStatusQuerySchema
}).query;
const { tmbId, outLink } = await authOutLinkCrud({
req,
authToken: true,
outLinkId,
per: ManagePermissionVal
});
await assertWechatOutLink(outLink);
const cacheKey = getWechatQrcodeCacheKey({ outLinkId, tmbId });
const raw = await getRedisCache(cacheKey);
if (!raw) { if (!raw) {
return { status: 'expired' }; return WechatQrcodeStatusResponseSchema.parse({ status: 'expired' });
} }
const qrData = JSON.parse(raw); const qrData = JSON.parse(raw);
...@@ -19,7 +47,7 @@ async function handler(req: ApiRequestProps<{}, { shareId: string }>): Promise<{ ...@@ -19,7 +47,7 @@ async function handler(req: ApiRequestProps<{}, { shareId: string }>): Promise<{
if (statusData.status === 'confirmed' && statusData.bot_token && statusData.ilink_bot_id) { if (statusData.status === 'confirmed' && statusData.bot_token && statusData.ilink_bot_id) {
await MongoOutLink.updateOne( await MongoOutLink.updateOne(
{ shareId }, { _id: outLink._id },
{ {
$set: { $set: {
'app.token': statusData.bot_token, 'app.token': statusData.bot_token,
...@@ -34,11 +62,11 @@ async function handler(req: ApiRequestProps<{}, { shareId: string }>): Promise<{ ...@@ -34,11 +62,11 @@ async function handler(req: ApiRequestProps<{}, { shareId: string }>): Promise<{
} }
); );
await delRedisCache(`publish:wechat:qrcode:${shareId}`); await delRedisCache(cacheKey);
await startWechatPolling(shareId); await startWechatPolling(outLink.shareId);
} }
return { status: statusData.status }; return WechatQrcodeStatusResponseSchema.parse({ status: statusData.status });
} }
export default NextAPI(handler); export default NextAPI(handler);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment