Commit 14ff938b by Archer Committed by GitHub

feat: bind homepage visitor id after authentication (#7319)

* feat: connect home attribution to CRM identity

* refactor: keep only visitor id attribution

* refactor: remove fastgpt source query parameter

* refactor: remove fastgpt sem source field

* refactor: report unified visitor contact

* fix: prefer stored visitor identity on login

* fix: validate visitor ids and decouple CRM reporting

* fix: validate visitor id before client storage

* fix: allow internal CRM endpoints
parent 0db2c260
...@@ -195,6 +195,9 @@ services: ...@@ -195,6 +195,9 @@ services:
AIPROXY_API_TOKEN: *x-aiproxy-token AIPROXY_API_TOKEN: *x-aiproxy-token
# MCP Server 代理地址,用于 MCP 使用方式页拼接 SSE 地址 # MCP Server 代理地址,用于 MCP 使用方式页拼接 SSE 地址
SSE_MCP_SERVER_PROXY_ENDPOINT: SSE_MCP_SERVER_PROXY_ENDPOINT:
# 官网访客归因 CRM;地址需包含 /api/v1,为空时不进行身份上报
CRM_API_URL:
CRM_API_KEY:
# ==================== 日志与监控 ==================== # ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称 # 传递给 OTLP 收集器的服务名称
......
...@@ -43,6 +43,8 @@ These variables are mainly validated by `packages/service/env.ts` and apply to ` ...@@ -43,6 +43,8 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
| `AIPROXY_API_TOKEN` | Empty | Token for calling AI Proxy. | | `AIPROXY_API_TOKEN` | Empty | Token for calling AI Proxy. |
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Default OpenAI-compatible model endpoint when AI Proxy is not configured. | | `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Default OpenAI-compatible model endpoint when AI Proxy is not configured. |
| `CHAT_API_KEY` | Empty | Default OpenAI-compatible model API key when AI Proxy token is not configured. | | `CHAT_API_KEY` | Empty | Default OpenAI-compatible model API key when AI Proxy token is not configured. |
| `CRM_API_URL` | Empty | Lead attribution CRM API base URL (including `/api/v1`). Empty disables identity reporting. |
| `CRM_API_KEY` | Empty | CRM admin API key used to bind a FastGPT user to `visitor_id` after registration or login. |
| `MARKETPLACE_URL` | `https://v2.marketplace.fastgpt.cn` | Plugin marketplace API URL. | | `MARKETPLACE_URL` | `https://v2.marketplace.fastgpt.cn` | Plugin marketplace API URL. |
| `FEISHU_BASE_URL` | `https://open.feishu.cn` | Lark Open Platform URL. Use your private Lark domain when self-hosting Lark. | | `FEISHU_BASE_URL` | `https://open.feishu.cn` | Lark Open Platform URL. Use your private Lark domain when self-hosting Lark. |
| `DINGTALK_BASE_URL` | `https://api.dingtalk.com` | DingTalk new API base URL. | | `DINGTALK_BASE_URL` | `https://api.dingtalk.com` | DingTalk new API base URL. |
......
...@@ -43,6 +43,8 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说 ...@@ -43,6 +43,8 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
| `AIPROXY_API_TOKEN` | 空 | 调用 AI Proxy 使用的认证 Token。 | | `AIPROXY_API_TOKEN` | 空 | 调用 AI Proxy 使用的认证 Token。 |
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | 未配置 AI Proxy 时,兼容 OpenAI 协议的默认模型接口地址。 | | `OPENAI_BASE_URL` | `https://api.openai.com/v1` | 未配置 AI Proxy 时,兼容 OpenAI 协议的默认模型接口地址。 |
| `CHAT_API_KEY` | 空 | 未配置 AI Proxy Token 时,兼容 OpenAI 协议的默认模型 API Key。 | | `CHAT_API_KEY` | 空 | 未配置 AI Proxy Token 时,兼容 OpenAI 协议的默认模型 API Key。 |
| `CRM_API_URL` | 空 | 官网访客归因 CRM 的 API 基础地址(包含 `/api/v1`);为空时不进行身份上报。 |
| `CRM_API_KEY` | 空 | CRM 管理 API Key,用于注册或登录成功后按 `visitor_id` 绑定 FastGPT 用户。 |
| `MARKETPLACE_URL` | `https://v2.marketplace.fastgpt.cn` | 插件市场接口地址。 | | `MARKETPLACE_URL` | `https://v2.marketplace.fastgpt.cn` | 插件市场接口地址。 |
| `FEISHU_BASE_URL` | `https://open.feishu.cn` | 飞书开放平台地址,私有化飞书可改为对应域名。 | | `FEISHU_BASE_URL` | `https://open.feishu.cn` | 飞书开放平台地址,私有化飞书可改为对应域名。 |
| `DINGTALK_BASE_URL` | `https://api.dingtalk.com` | 钉钉新版 API 基础地址。 | | `DINGTALK_BASE_URL` | `https://api.dingtalk.com` | 钉钉新版 API 基础地址。 |
......
...@@ -58,33 +58,31 @@ export const PreLoginResponseSchema = z ...@@ -58,33 +58,31 @@ export const PreLoginResponseSchema = z
export type PreLoginResponseType = z.infer<typeof PreLoginResponseSchema>; export type PreLoginResponseType = z.infer<typeof PreLoginResponseSchema>;
// ===== Login by password ===== // ===== Login by password =====
export const LoginByPasswordBodySchema = z export const LoginByPasswordBodySchema = TrackRegisterParamsSchema.extend({
.object({ username: z.string().meta({
username: z.string().meta({ example: 'admin',
example: 'admin', description: '用户名'
description: '用户名' }),
}), password: z.string().meta({
password: z.string().meta({ example: 'hashed_password',
example: 'hashed_password', description: '密码'
description: '密码' }),
}), code: z.string().meta({
code: z.string().meta({ example: '123456',
example: '123456', description: '预登录验证码'
description: '预登录验证码' }),
}), language: LanguageSchema.optional().default('zh-CN').meta({
language: LanguageSchema.optional().default('zh-CN').meta({ example: 'zh-CN',
example: 'zh-CN', description: '用户语言偏好'
description: '用户语言偏好'
})
}) })
.meta({ }).meta({
example: { example: {
username: 'admin', username: 'admin',
password: 'hashed_password', password: 'hashed_password',
code: '123456', code: '123456',
language: 'zh-CN' language: 'zh-CN'
} }
}); });
export type LoginByPasswordBodyType = z.infer<typeof LoginByPasswordBodySchema>; export type LoginByPasswordBodyType = z.infer<typeof LoginByPasswordBodySchema>;
/* ===== Wecom Login ===== */ /* ===== Wecom Login ===== */
...@@ -107,7 +105,7 @@ export const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({ ...@@ -107,7 +105,7 @@ export const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({
export type OauthLoginBodyType = z.infer<typeof OauthLoginBodySchema>; export type OauthLoginBodyType = z.infer<typeof OauthLoginBodySchema>;
// ===== Fast Login ===== // ===== Fast Login =====
export const FastLoginBodySchema = z.object({ export const FastLoginBodySchema = TrackRegisterParamsSchema.extend({
token: z.string().meta({ description: 'Token' }), token: z.string().meta({ description: 'Token' }),
code: z.string().meta({ description: 'Code' }), code: z.string().meta({ description: 'Code' }),
language: LanguageSchema.optional().meta({ description: '语言' }) language: LanguageSchema.optional().meta({ description: '语言' })
......
...@@ -7,11 +7,13 @@ export const ShortUrlSchema = z.object({ ...@@ -7,11 +7,13 @@ export const ShortUrlSchema = z.object({
}); });
export type ShortUrlParams = z.infer<typeof ShortUrlSchema>; export type ShortUrlParams = z.infer<typeof ShortUrlSchema>;
export const VisitorIdSchema = z.string().trim().min(1).max(64);
export const FastGPT_SEM_Schema = ShortUrlSchema.extend({ export const FastGPT_SEM_Schema = ShortUrlSchema.extend({
keyword: z.string().optional(), keyword: z.string().optional(),
search: z.string().optional(), search: z.string().optional(),
source: z.string().optional(), sourceDomain: z.string().optional(),
sourceDomain: z.string().optional() visitor_id: VisitorIdSchema.optional()
}); });
export type FastGPTSemType = z.infer<typeof FastGPT_SEM_Schema>; export type FastGPTSemType = z.infer<typeof FastGPT_SEM_Schema>;
......
import { describe, expect, it } from 'vitest';
import { VisitorIdSchema } from '@fastgpt/global/support/marketing/type';
describe('VisitorIdSchema', () => {
it('accepts and trims homepage visitor ids', () => {
expect(VisitorIdSchema.parse(' 550e8400-e29b-41d4-a716-446655440000 ')).toBe(
'550e8400-e29b-41d4-a716-446655440000'
);
expect(VisitorIdSchema.parse('fg_m1_test_123')).toBe('fg_m1_test_123');
expect(VisitorIdSchema.parse('visitor/with/path')).toBe('visitor/with/path');
});
it('rejects empty and oversized visitor ids', () => {
expect(VisitorIdSchema.safeParse(' ').success).toBe(false);
expect(VisitorIdSchema.safeParse('a'.repeat(65)).success).toBe(false);
});
});
...@@ -60,6 +60,10 @@ export const serviceEnv = createEnv({ ...@@ -60,6 +60,10 @@ export const serviceEnv = createEnv({
PRO_URL: UrlSchema.optional(), PRO_URL: UrlSchema.optional(),
PRO_TOKEN: z.string().min(32, 'PRO_TOKEN must be at least 32 characters').optional(), PRO_TOKEN: z.string().min(32, 'PRO_TOKEN must be at least 32 characters').optional(),
// 官网访客归因 CRM;未配置地址时不进行身份上报
CRM_API_URL: UrlSchema.optional(),
CRM_API_KEY: z.string().optional(),
// Agent sandbox proxy // Agent sandbox proxy
AGENT_SANDBOX_PROXY_SECRET: z AGENT_SANDBOX_PROXY_SECRET: z
.string() .string()
......
import { axiosWithoutSSRF } from '../../common/api/axios';
import { getLogger, LogCategories } from '../../common/logger';
import { serviceEnv } from '../../env';
import { FastGPT_SEM_Schema } from '@fastgpt/global/support/marketing/type';
const logger = getLogger(LogCategories.MODULE.USER.ACCOUNT);
type ReportCRMVisitorIdentityProps = {
visitorId?: string;
userId: string;
username: string;
contact?: string;
};
const getContact = (username: string, contact?: string) => {
const candidates = [contact, username].map((value) => value?.trim()).filter(Boolean) as string[];
const email = candidates.find((value) => value.includes('@'));
if (email) return email;
return candidates.find((value) => /^\+?[\d\s()-]{6,20}$/.test(value));
};
export const resolveCRMVisitorId = ({
storedFastgptSem,
incomingVisitorId
}: {
storedFastgptSem?: unknown;
incomingVisitorId?: string;
}) => {
const parsedFastgptSem = FastGPT_SEM_Schema.safeParse(storedFastgptSem);
const fastgptSem = parsedFastgptSem.success ? parsedFastgptSem.data : {};
const storedVisitorId = fastgptSem.visitor_id?.trim();
const normalizedIncomingVisitorId = incomingVisitorId?.trim();
const shouldPersist = !storedVisitorId && !!normalizedIncomingVisitorId;
return {
visitorId: storedVisitorId || normalizedIncomingVisitorId,
shouldPersist,
fastgptSem: shouldPersist
? { ...fastgptSem, visitor_id: normalizedIncomingVisitorId }
: fastgptSem
};
};
/**
* 将官网匿名 visitor_id 与 FastGPT 用户绑定。
* 上报失败只记日志,不能影响注册或登录结果。
*/
export const reportCRMVisitorIdentity = async ({
visitorId: rawVisitorId,
userId,
username,
contact
}: ReportCRMVisitorIdentityProps): Promise<void> => {
const crmApiUrl = serviceEnv.CRM_API_URL?.replace(/\/$/, '');
const visitorId = rawVisitorId?.trim();
if (!crmApiUrl || !visitorId) return;
if (!serviceEnv.CRM_API_KEY) {
logger.warn('Skip CRM visitor identity report: CRM_API_KEY is not configured');
return;
}
const normalizedContact = getContact(username, contact);
try {
await axiosWithoutSSRF.patch(
`${crmApiUrl}/contacts/visitor/${encodeURIComponent(visitorId)}/identity`,
{
cloud_user_id: userId,
...(normalizedContact && { contact: normalizedContact })
},
{
headers: {
'X-API-Key': serviceEnv.CRM_API_KEY
},
timeout: 5000
}
);
} catch (error) {
logger.warn('CRM visitor identity report failed', {
error,
visitorId,
userId
});
}
};
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
patch: vi.fn(),
warn: vi.fn(),
serviceEnv: {
CRM_API_URL: undefined as string | undefined,
CRM_API_KEY: undefined as string | undefined
}
}));
vi.mock('@fastgpt/service/common/api/axios', () => ({
axiosWithoutSSRF: { patch: mocks.patch }
}));
vi.mock('@fastgpt/service/common/logger', () => ({
getLogger: () => ({ warn: mocks.warn }),
LogCategories: { MODULE: { USER: { ACCOUNT: ['user', 'account'] } } }
}));
vi.mock('@fastgpt/service/env', () => ({
serviceEnv: mocks.serviceEnv
}));
import {
reportCRMVisitorIdentity,
resolveCRMVisitorId
} from '@fastgpt/service/support/marketing/attribution';
describe('resolveCRMVisitorId', () => {
it('prefers the visitor id stored on the user', () => {
expect(
resolveCRMVisitorId({
storedFastgptSem: { visitor_id: 'stored-visitor' },
incomingVisitorId: 'incoming-visitor'
})
).toEqual({
visitorId: 'stored-visitor',
shouldPersist: false,
fastgptSem: { visitor_id: 'stored-visitor' }
});
});
it('uses and persists the incoming visitor id when the user has none', () => {
expect(
resolveCRMVisitorId({
storedFastgptSem: { keyword: 'FastGPT' },
incomingVisitorId: ' incoming-visitor '
})
).toEqual({
visitorId: 'incoming-visitor',
shouldPersist: true,
fastgptSem: { keyword: 'FastGPT', visitor_id: 'incoming-visitor' }
});
});
});
describe('reportCRMVisitorIdentity', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.serviceEnv.CRM_API_URL = undefined;
mocks.serviceEnv.CRM_API_KEY = undefined;
});
it('skips reporting when CRM_API_URL is not configured', async () => {
await reportCRMVisitorIdentity({
visitorId: 'visitor-1',
userId: 'user-1',
username: 'user@example.com'
});
expect(mocks.patch).not.toHaveBeenCalled();
});
it('skips reporting when visitor_id is missing', async () => {
mocks.serviceEnv.CRM_API_URL = 'https://crm.example.com/api/v1';
mocks.serviceEnv.CRM_API_KEY = 'crm-key';
await reportCRMVisitorIdentity({
userId: 'user-1',
username: 'user@example.com'
});
expect(mocks.patch).not.toHaveBeenCalled();
});
it('reports the FastGPT identity using the visitor_id', async () => {
mocks.serviceEnv.CRM_API_URL = 'https://crm.example.com/api/v1/';
mocks.serviceEnv.CRM_API_KEY = 'crm-key';
await reportCRMVisitorIdentity({
visitorId: 'visitor/1',
userId: 'user-1',
username: '13800138000',
contact: 'user@example.com'
});
expect(mocks.patch).toHaveBeenCalledWith(
'https://crm.example.com/api/v1/contacts/visitor/visitor%2F1/identity',
{
cloud_user_id: 'user-1',
contact: 'user@example.com'
},
{
headers: { 'X-API-Key': 'crm-key' },
timeout: 5000
}
);
});
it('reports a phone number when no email is available', async () => {
mocks.serviceEnv.CRM_API_URL = 'https://crm.example.com/api/v1';
mocks.serviceEnv.CRM_API_KEY = 'crm-key';
await reportCRMVisitorIdentity({
visitorId: 'visitor-1',
userId: 'user-1',
username: '13800138000'
});
expect(mocks.patch).toHaveBeenCalledWith(
'https://crm.example.com/api/v1/contacts/visitor/visitor-1/identity',
{
cloud_user_id: 'user-1',
contact: '13800138000'
},
expect.any(Object)
);
});
it('does not fail login when CRM reporting fails', async () => {
mocks.serviceEnv.CRM_API_URL = 'https://crm.example.com/api/v1';
mocks.serviceEnv.CRM_API_KEY = 'crm-key';
mocks.patch.mockRejectedValueOnce(new Error('CRM unavailable'));
await expect(
reportCRMVisitorIdentity({
visitorId: 'visitor-1',
userId: 'user-1',
username: 'user@example.com'
})
).resolves.toBeUndefined();
expect(mocks.warn).toHaveBeenCalledWith(
'CRM visitor identity report failed',
expect.objectContaining({ visitorId: 'visitor-1', userId: 'user-1' })
);
});
});
...@@ -22,6 +22,10 @@ ROOT_KEY=fdafasd ...@@ -22,6 +22,10 @@ ROOT_KEY=fdafasd
# PRO_URL= # PRO_URL=
# PRO_TOKEN= # PRO_TOKEN=
# 官网访客归因 CRM(地址未配置时不进行身份上报)
# CRM_API_URL=https://crm.example.com/api/v1
# CRM_API_KEY=
# 插件服务 # 插件服务
PLUGIN_BASE_URL=http://localhost:3004 PLUGIN_BASE_URL=http://localhost:3004
PLUGIN_TOKEN=XHgR8zvKx1FhjHUxCKdJMNpzFUMlavM1 PLUGIN_TOKEN=XHgR8zvKx1FhjHUxCKdJMNpzFUMlavM1
......
...@@ -15,6 +15,7 @@ import type { LangEnum } from '@fastgpt/global/common/i18n/type'; ...@@ -15,6 +15,7 @@ import type { LangEnum } from '@fastgpt/global/common/i18n/type';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import PolicyTip from './PolicyTip'; import PolicyTip from './PolicyTip';
import { getRegisterMethods } from '@/web/common/system/utils'; import { getRegisterMethods } from '@/web/common/system/utils';
import { getFastGPTSem, onFastGPTLoginSuccess } from '@/web/support/marketing/utils';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>; type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>;
...@@ -50,9 +51,10 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => { ...@@ -50,9 +51,10 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => {
username, username,
password, password,
code, code,
fastgpt_sem: getFastGPTSem(),
language: i18n.language as LangEnum language: i18n.language as LangEnum
}); });
await loginSuccess(loginResponse); await onFastGPTLoginSuccess(loginSuccess, loginResponse);
}, },
{ {
refreshDeps: [loginSuccess], refreshDeps: [loginSuccess],
......
...@@ -13,8 +13,8 @@ import { ...@@ -13,8 +13,8 @@ import {
getBdVId, getBdVId,
getFastGPTSem, getFastGPTSem,
getMsclkid, getMsclkid,
removeFastGPTSem, getInviterId,
getInviterId onFastGPTLoginSuccess
} from '@/web/support/marketing/utils'; } from '@/web/support/marketing/utils';
import PolicyTip from './PolicyTip'; import PolicyTip from './PolicyTip';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
...@@ -56,8 +56,7 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => { ...@@ -56,8 +56,7 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => {
enabled: !!wechatInfo?.code, enabled: !!wechatInfo?.code,
async onSuccess(data: LoginSuccessResponseType | undefined) { async onSuccess(data: LoginSuccessResponseType | undefined) {
if (data) { if (data) {
removeFastGPTSem(); await onFastGPTLoginSuccess(loginSuccess, data);
await loginSuccess(data);
} }
} }
} }
......
...@@ -13,7 +13,7 @@ import { ...@@ -13,7 +13,7 @@ import {
getFastGPTSem, getFastGPTSem,
getInviterId, getInviterId,
getMsclkid, getMsclkid,
removeFastGPTSem onFastGPTLoginSuccess
} from '@/web/support/marketing/utils'; } from '@/web/support/marketing/utils';
import { checkPasswordRule } from '@fastgpt/global/common/string/password'; import { checkPasswordRule } from '@fastgpt/global/common/string/password';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
...@@ -64,8 +64,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { ...@@ -64,8 +64,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
fastgpt_sem: getFastGPTSem(), fastgpt_sem: getFastGPTSem(),
language: i18n.language as LangEnum language: i18n.language as LangEnum
}); });
await loginSuccess(loginResponse); await onFastGPTLoginSuccess(loginSuccess, loginResponse);
removeFastGPTSem();
toast({ toast({
status: 'success', status: 'success',
......
...@@ -21,12 +21,16 @@ import { ...@@ -21,12 +21,16 @@ import {
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type';
import { getClientIpFromRequest } from '@fastgpt/service/common/security/clientIp'; import { getClientIpFromRequest } from '@fastgpt/service/common/security/clientIp';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
reportCRMVisitorIdentity,
resolveCRMVisitorId
} from '@fastgpt/service/support/marketing/attribution';
async function handler( async function handler(
req: ApiRequestProps<LoginByPasswordBodyType>, req: ApiRequestProps<LoginByPasswordBodyType>,
res: ApiResponseType res: ApiResponseType
): Promise<LoginSuccessResponseType> { ): Promise<LoginSuccessResponseType> {
const { username, password, code, language } = parseApiInput({ const { username, password, code, language, fastgpt_sem } = parseApiInput({
req, req,
bodySchema: LoginByPasswordBodySchema bodySchema: LoginByPasswordBodySchema
}).body; }).body;
...@@ -64,6 +68,13 @@ async function handler( ...@@ -64,6 +68,13 @@ async function handler(
user.lastLoginTmbId = userDetail.team.tmbId; user.lastLoginTmbId = userDetail.team.tmbId;
user.language = language; user.language = language;
const visitorIdentity = resolveCRMVisitorId({
storedFastgptSem: user.fastgpt_sem,
incomingVisitorId: fastgpt_sem?.visitor_id
});
if (visitorIdentity.shouldPersist) {
user.fastgpt_sem = visitorIdentity.fastgptSem;
}
await user.save(); await user.save();
const token = await createUserSession({ const token = await createUserSession({
...@@ -76,6 +87,13 @@ async function handler( ...@@ -76,6 +87,13 @@ async function handler(
setCookie(res, token); setCookie(res, token);
void reportCRMVisitorIdentity({
visitorId: visitorIdentity.visitorId,
userId: String(user._id),
username: user.username,
contact: user.contact
});
pushTrack.login({ pushTrack.login({
type: 'password', type: 'password',
uid: user._id, uid: user._id,
......
...@@ -12,6 +12,7 @@ import { validateRedirectUrl } from '@/web/common/utils/uri'; ...@@ -12,6 +12,7 @@ import { validateRedirectUrl } from '@/web/common/utils/uri';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import { useLoginRedirectAfterLogin } from '@/web/support/user/loginRedirect'; import { useLoginRedirectAfterLogin } from '@/web/support/user/loginRedirect';
import type { LangEnum } from '@fastgpt/global/common/i18n/type'; import type { LangEnum } from '@fastgpt/global/common/i18n/type';
import { getFastGPTSem, onFastGPTLoginSuccess } from '@/web/support/marketing/utils';
const FastLogin = ({ const FastLogin = ({
code, code,
...@@ -55,6 +56,7 @@ const FastLogin = ({ ...@@ -55,6 +56,7 @@ const FastLogin = ({
const res = await postFastLogin({ const res = await postFastLogin({
code, code,
token, token,
fastgpt_sem: getFastGPTSem(),
language: i18n.language as LangEnum language: i18n.language as LangEnum
}); });
if (!res) { if (!res) {
...@@ -66,7 +68,7 @@ const FastLogin = ({ ...@@ -66,7 +68,7 @@ const FastLogin = ({
router.replace('/login'); router.replace('/login');
}, 1000); }, 1000);
} }
await loginSuccess(res); await onFastGPTLoginSuccess(loginSuccess, res);
} catch (error) { } catch (error) {
toast({ toast({
status: 'warning', status: 'warning',
......
...@@ -15,7 +15,7 @@ import { ...@@ -15,7 +15,7 @@ import {
getFastGPTSem, getFastGPTSem,
getInviterId, getInviterId,
getMsclkid, getMsclkid,
removeFastGPTSem onFastGPTLoginSuccess
} from '@/web/support/marketing/utils'; } from '@/web/support/marketing/utils';
import { postAcceptInvitationLink } from '@/web/support/user/team/api'; import { postAcceptInvitationLink } from '@/web/support/user/team/api';
import { retryFn } from '@fastgpt/global/common/system/utils'; import { retryFn } from '@fastgpt/global/common/system/utils';
...@@ -103,8 +103,7 @@ const provider = () => { ...@@ -103,8 +103,7 @@ const provider = () => {
}, 1000); }, 1000);
} }
removeFastGPTSem(); await onFastGPTLoginSuccess(loginSuccess, res);
await loginSuccess(res);
} catch (error) { } catch (error) {
toast({ toast({
status: 'warning', status: 'warning',
......
...@@ -26,7 +26,7 @@ type MarketingQueryParams = { ...@@ -26,7 +26,7 @@ type MarketingQueryParams = {
msclkid?: string; msclkid?: string;
k?: string; k?: string;
search?: string; search?: string;
fastgpt_source?: string; visitor_id?: string;
sourceDomain?: string; sourceDomain?: string;
utm_source?: string; utm_source?: string;
utm_medium?: string; utm_medium?: string;
...@@ -40,7 +40,7 @@ const MARKETING_PARAMS: (keyof MarketingQueryParams)[] = [ ...@@ -40,7 +40,7 @@ const MARKETING_PARAMS: (keyof MarketingQueryParams)[] = [
'bd_vid', 'bd_vid',
'msclkid', 'msclkid',
'k', 'k',
'fastgpt_source', 'visitor_id',
'sourceDomain', 'sourceDomain',
'utm_source', 'utm_source',
'utm_medium', 'utm_medium',
...@@ -57,7 +57,7 @@ export const useInitApp = () => { ...@@ -57,7 +57,7 @@ export const useInitApp = () => {
msclkid, msclkid,
k, k,
search, search,
fastgpt_source, visitor_id,
sourceDomain, sourceDomain,
utm_source, utm_source,
utm_medium, utm_medium,
...@@ -165,10 +165,11 @@ export const useInitApp = () => { ...@@ -165,10 +165,11 @@ export const useInitApp = () => {
if (utm_workflow) { if (utm_workflow) {
setUtmParams(utmParams); setUtmParams(utmParams);
} }
setFastGPTSem({ setFastGPTSem({
keyword: k, keyword: k,
search, search,
source: fastgpt_source, visitor_id,
...utmParams ...utmParams
}); });
......
import { import {
FastGPT_SEM_Schema,
type ShortUrlParams, type ShortUrlParams,
type TrackRegisterParams type TrackRegisterParams
} from '@fastgpt/global/support/marketing/type'; } from '@fastgpt/global/support/marketing/type';
...@@ -59,15 +60,30 @@ export const removeUtmParams = () => { ...@@ -59,15 +60,30 @@ export const removeUtmParams = () => {
localStorage.removeItem('utm_params'); localStorage.removeItem('utm_params');
}; };
export const getFastGPTSem = () => { export const getFastGPTSem = (): TrackRegisterParams['fastgpt_sem'] => {
try { try {
return localStorage.getItem('fastgpt_sem') const value = localStorage.getItem('fastgpt_sem');
? JSON.parse(localStorage.getItem('fastgpt_sem')!) if (!value) return undefined;
: undefined;
const result = FastGPT_SEM_Schema.safeParse(JSON.parse(value));
if (result.success) return result.data;
localStorage.removeItem('fastgpt_sem');
return undefined;
} catch { } catch {
localStorage.removeItem('fastgpt_sem');
return undefined; return undefined;
} }
}; };
export const onFastGPTLoginSuccess = async <T>(
loginSuccess: (result: T) => void | Promise<void>,
result: T
) => {
await loginSuccess(result);
removeFastGPTSem();
};
export const setFastGPTSem = (fastgptSem?: TrackRegisterParams['fastgpt_sem']) => { export const setFastGPTSem = (fastgptSem?: TrackRegisterParams['fastgpt_sem']) => {
if (!fastgptSem) return; if (!fastgptSem) return;
...@@ -76,14 +92,13 @@ export const setFastGPTSem = (fastgptSem?: TrackRegisterParams['fastgpt_sem']) = ...@@ -76,14 +92,13 @@ export const setFastGPTSem = (fastgptSem?: TrackRegisterParams['fastgpt_sem']) =
const currentFastGPTSem = getFastGPTSem(); const currentFastGPTSem = getFastGPTSem();
const nextFastGPTSem = Object.fromEntries(validEntries); const nextFastGPTSem = Object.fromEntries(validEntries);
const result = FastGPT_SEM_Schema.safeParse({
...currentFastGPTSem,
...nextFastGPTSem
});
localStorage.setItem( if (!result.success) return;
'fastgpt_sem', localStorage.setItem('fastgpt_sem', JSON.stringify(result.data));
JSON.stringify({
...currentFastGPTSem,
...nextFastGPTSem
})
);
}; };
export const removeFastGPTSem = () => { export const removeFastGPTSem = () => {
localStorage.removeItem('fastgpt_sem'); localStorage.removeItem('fastgpt_sem');
......
...@@ -49,7 +49,7 @@ describe('loginByPassword API', () => { ...@@ -49,7 +49,7 @@ describe('loginByPassword API', () => {
}); });
it('should login successfully with valid credentials', async () => { it('should login successfully with valid credentials', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: 'testpassword', password: 'testpassword',
...@@ -84,7 +84,7 @@ describe('loginByPassword API', () => { ...@@ -84,7 +84,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject login when username is empty', async () => { it('should reject login when username is empty', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: '', username: '',
password: 'testpassword', password: 'testpassword',
...@@ -97,7 +97,7 @@ describe('loginByPassword API', () => { ...@@ -97,7 +97,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject login when password is empty', async () => { it('should reject login when password is empty', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: '', password: '',
...@@ -114,7 +114,7 @@ describe('loginByPassword API', () => { ...@@ -114,7 +114,7 @@ describe('loginByPassword API', () => {
it('should reject login when auth code verification fails', async () => { it('should reject login when auth code verification fails', async () => {
vi.mocked(authCode).mockRejectedValueOnce(new Error('Invalid code')); vi.mocked(authCode).mockRejectedValueOnce(new Error('Invalid code'));
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: 'testpassword', password: 'testpassword',
...@@ -128,7 +128,7 @@ describe('loginByPassword API', () => { ...@@ -128,7 +128,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject login when user does not exist', async () => { it('should reject login when user does not exist', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'nonexistentuser', username: 'nonexistentuser',
password: 'testpassword', password: 'testpassword',
...@@ -146,7 +146,7 @@ describe('loginByPassword API', () => { ...@@ -146,7 +146,7 @@ describe('loginByPassword API', () => {
status: UserStatusEnum.forbidden status: UserStatusEnum.forbidden
}); });
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: 'testpassword', password: 'testpassword',
...@@ -160,7 +160,7 @@ describe('loginByPassword API', () => { ...@@ -160,7 +160,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject login when password is incorrect', async () => { it('should reject login when password is incorrect', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: 'wrongpassword', password: 'wrongpassword',
...@@ -174,7 +174,7 @@ describe('loginByPassword API', () => { ...@@ -174,7 +174,7 @@ describe('loginByPassword API', () => {
}); });
it('should update language on successful login', async () => { it('should update language on successful login', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: 'testpassword', password: 'testpassword',
...@@ -190,6 +190,48 @@ describe('loginByPassword API', () => { ...@@ -190,6 +190,48 @@ describe('loginByPassword API', () => {
expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id); expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id);
}); });
it('should persist visitor_id on successful login', async () => {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456',
fastgpt_sem: {
visitor_id: 'visitor-1'
},
language: 'zh-CN'
}
});
expect(res.code).toBe(200);
const updatedUser = await MongoUser.findById(testUser._id).lean();
expect(updatedUser?.fastgpt_sem).toMatchObject({ visitor_id: 'visitor-1' });
});
it('should keep the stored visitor_id when login carries a different one', async () => {
await MongoUser.findByIdAndUpdate(testUser._id, {
fastgpt_sem: { visitor_id: 'stored-visitor' }
});
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456',
fastgpt_sem: {
visitor_id: 'incoming-visitor'
},
language: 'zh-CN'
}
});
expect(res.code).toBe(200);
const updatedUser = await MongoUser.findById(testUser._id).lean();
expect(updatedUser?.fastgpt_sem).toMatchObject({ visitor_id: 'stored-visitor' });
});
it('should handle root user login correctly', async () => { it('should handle root user login correctly', async () => {
const rootUser = await MongoUser.create({ const rootUser = await MongoUser.create({
username: 'root', username: 'root',
...@@ -217,7 +259,7 @@ describe('loginByPassword API', () => { ...@@ -217,7 +259,7 @@ describe('loginByPassword API', () => {
lastLoginTmbId: rootTmb._id lastLoginTmbId: rootTmb._id
}); });
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, { const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'root', username: 'root',
password: 'rootpassword', password: 'rootpassword',
...@@ -236,7 +278,7 @@ describe('loginByPassword API', () => { ...@@ -236,7 +278,7 @@ describe('loginByPassword API', () => {
describe('NoSQL injection prevention', () => { describe('NoSQL injection prevention', () => {
it('should reject password as object with MongoDB operator ($ne)', async () => { it('should reject password as object with MongoDB operator ($ne)', async () => {
// GHSA-jxvr-h2vx-p73r Step 2: password: {"$ne": ""} bypasses password check // GHSA-jxvr-h2vx-p73r Step 2: password: {"$ne": ""} bypasses password check
const res = await Call<any, {}, any>(loginApi.default, { const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: { $ne: '' }, password: { $ne: '' },
...@@ -252,7 +294,7 @@ describe('loginByPassword API', () => { ...@@ -252,7 +294,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject password with $regex operator', async () => { it('should reject password with $regex operator', async () => {
const res = await Call<any, {}, any>(loginApi.default, { const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: { $regex: '.*' }, password: { $regex: '.*' },
...@@ -265,7 +307,7 @@ describe('loginByPassword API', () => { ...@@ -265,7 +307,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject password with $where injection', async () => { it('should reject password with $where injection', async () => {
const res = await Call<any, {}, any>(loginApi.default, { const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: { $where: 'return true' }, password: { $where: 'return true' },
...@@ -278,7 +320,7 @@ describe('loginByPassword API', () => { ...@@ -278,7 +320,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject username as object with MongoDB operator', async () => { it('should reject username as object with MongoDB operator', async () => {
const res = await Call<any, {}, any>(loginApi.default, { const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: { $ne: '' }, username: { $ne: '' },
password: 'testpassword', password: 'testpassword',
...@@ -291,7 +333,7 @@ describe('loginByPassword API', () => { ...@@ -291,7 +333,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject code as object with MongoDB operator', async () => { it('should reject code as object with MongoDB operator', async () => {
const res = await Call<any, {}, any>(loginApi.default, { const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: 'testpassword', password: 'testpassword',
...@@ -304,7 +346,7 @@ describe('loginByPassword API', () => { ...@@ -304,7 +346,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject all fields as injection objects simultaneously', async () => { it('should reject all fields as injection objects simultaneously', async () => {
const res = await Call<any, {}, any>(loginApi.default, { const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: { $ne: '' }, username: { $ne: '' },
password: { $ne: '' }, password: { $ne: '' },
...@@ -317,7 +359,7 @@ describe('loginByPassword API', () => { ...@@ -317,7 +359,7 @@ describe('loginByPassword API', () => {
}); });
it('should reject password as non-string types (array, number)', async () => { it('should reject password as non-string types (array, number)', async () => {
const arrayRes = await Call<any, {}, any>(loginApi.default, { const arrayRes = await Call<any, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: ['testpassword'], password: ['testpassword'],
...@@ -327,7 +369,7 @@ describe('loginByPassword API', () => { ...@@ -327,7 +369,7 @@ describe('loginByPassword API', () => {
}); });
expect(arrayRes.code).toBe(500); expect(arrayRes.code).toBe(500);
const numberRes = await Call<any, {}, any>(loginApi.default, { const numberRes = await Call<any, Record<string, never>, any>(loginApi.default, {
body: { body: {
username: 'testuser', username: 'testuser',
password: 12345, password: 12345,
......
...@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; ...@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
getFastGPTSem, getFastGPTSem,
initFastGPTSemSourceDomain, initFastGPTSemSourceDomain,
removeFastGPTSem onFastGPTLoginSuccess,
removeFastGPTSem,
setFastGPTSem
} from '@/web/support/marketing/utils'; } from '@/web/support/marketing/utils';
const storageMock = () => { const storageMock = () => {
...@@ -38,4 +40,49 @@ describe('marketing utils', () => { ...@@ -38,4 +40,49 @@ describe('marketing utils', () => {
expect(getFastGPTSem()?.sourceDomain).toBe('https://example.com'); expect(getFastGPTSem()?.sourceDomain).toBe('https://example.com');
}); });
it('should persist visitor_id without source attribution fields', () => {
setFastGPTSem({
visitor_id: ' visitor-1 '
});
expect(getFastGPTSem()).toEqual({
visitor_id: 'visitor-1'
});
});
it('should not persist an oversized visitor_id', () => {
setFastGPTSem({
visitor_id: 'a'.repeat(65)
});
expect(localStorage.getItem('fastgpt_sem')).toBeNull();
expect(getFastGPTSem()).toBeUndefined();
});
it('should discard unknown marketing fields', () => {
localStorage.setItem(
'fastgpt_sem',
JSON.stringify({
visitor_id: 'visitor-current',
unknown_field: 'discarded'
})
);
expect(getFastGPTSem()).toEqual({
visitor_id: 'visitor-current'
});
});
it('should clear pending marketing data after login succeeds', async () => {
setFastGPTSem({
visitor_id: 'visitor-1'
});
const loginSuccess = vi.fn();
await onFastGPTLoginSuccess(loginSuccess, { ok: true });
expect(loginSuccess).toHaveBeenCalledWith({ ok: true });
expect(getFastGPTSem()).toBeUndefined();
});
}); });
...@@ -50,7 +50,7 @@ describe('user api', () => { ...@@ -50,7 +50,7 @@ describe('user api', () => {
msclkid: 'click123', msclkid: 'click123',
fastgpt_sem: { fastgpt_sem: {
keyword: 'sem123', keyword: 'sem123',
source: 'home_hero_trial', visitor_id: 'visitor-1',
sourceDomain: 'https://example.com' sourceDomain: 'https://example.com'
} }
}; };
......
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