11 Commits

Author SHA1 Message Date
Liam
51e168ee3b eventid 2025-04-28 20:18:46 +08:00
Liam
cf0f35e274 activies api 2025-04-28 19:48:18 +08:00
3162836e91 Refactor route protection by replacing ProtectedRoute with ClientRouteGuard in analytics, create-shorturl, and links pages to standardize authentication handling across the application. 2025-04-24 01:41:44 +08:00
d80d5e976b Update authentication redirect to use environment variable for site URL, ensuring proper redirection based on configuration. This change enhances flexibility for different deployment environments. 2025-04-24 00:34:47 +08:00
5d5b501a66 Remove development and production environment configuration files (.env.development and .env.production) to streamline project setup and enhance security by eliminating sensitive information from version control. 2025-04-24 00:21:13 +08:00
fe40aad835 Enhance MongoDB to ClickHouse synchronization script by adding support for custom time range synchronization, allowing users to specify start and end dates. Update .env file to include MongoDB connection URL and add .gitignore for script dependencies. 2025-04-24 00:09:24 +08:00
92db5ad783 Update authentication redirects to use environment variable for site URL, enhancing flexibility for different environments. Add NEXT_PUBLIC_SITE_URL to .env for production URL configuration. 2025-04-23 22:38:56 +08:00
b94a91914a Enhance GET request validation in activities route to ensure both slug and domain are provided together, or alternatively, a date range. Update error messages for clarity. 2025-04-23 22:27:49 +08:00
8551f5c445 csv text 2025-04-23 21:16:32 +08:00
dafa7f53ac create en 2025-04-23 21:03:16 +08:00
0203cb4041 Add Google sign-in functionality to Login and Register pages, including error handling and UI updates for better user experience. 2025-04-23 20:52:04 +08:00
31 changed files with 1464 additions and 1209 deletions

6
.env
View File

@@ -1,5 +1,7 @@
PORT=3007
MONGO_URL="mongodb://10.0.1.41:27017"
# ClickHouse Configuration
CLICKHOUSE_HOST=10.0.1.60
CLICKHOUSE_PORT=8123
@@ -24,4 +26,6 @@ NEXT_PUBLIC_SUPABASE_URL="https://mwwvqwevplndzvmqmrxa.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im13d3Zxd2V2cGxuZHp2bXFtcnhhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDQzNDU2NDMsImV4cCI6MjA1OTkyMTY0M30.EI7OY0Aq3zYj6fRG_IUn4IlUZ89b0LOg0jb0nMLLKWU"
DATABASE_URL="postgresql://postgres.mwwvqwevplndzvmqmrxa:eYYdarJsRL*Z6&p9gD@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"
NEXT_PUBLIC_LIMQ_API="https://app.upj.to"
NEXT_PUBLIC_LIMQ_API="https://app.upj.to"
# Application URL for redirects (replace with your production URL)
NEXT_PUBLIC_SITE_URL="https://main.upj.to"

42
README-auth-setup.md Normal file
View File

@@ -0,0 +1,42 @@
# 身份验证重定向 URL 配置指南
## 问题:注册后确认邮件链接指向 localhost
如果您在生产环境中使用此应用,并且发现用户注册后收到的确认邮件中的链接指向 `localhost` 而非您的实际网站域名,请按照以下步骤解决:
## 解决方案
### 1. 设置环境变量
在项目根目录的 `.env.production` 文件中,确保 `NEXT_PUBLIC_SITE_URL` 变量设置为您的实际生产域名:
```
NEXT_PUBLIC_SITE_URL="https://您的真实域名.com"
```
### 2. 在 Supabase 控制台中配置
登录 [Supabase 控制台](https://app.supabase.com/),然后:
1. 选择您的项目
2. 导航到 **Authentication** > **URL Configuration**
3.**Site URL** 字段中输入您的实际网站 URL
4.**Redirect URLs** 部分添加:
- `https://您的真实域名.com/auth/callback`
### 3. 本地开发与生产环境
- **开发环境**:使用 `.env.development` 文件中的设置,通常为 `http://localhost:3007`
- **生产环境**:使用 `.env.production` 文件中的设置,应为您的实际域名
### 4. 部署后验证
项目重新部署后:
1. 尝试注册一个新账户
2. 检查收到的确认邮件,确认链接现在指向您的实际域名而非 localhost
## 技术说明
身份验证流程中,应用使用环境变量 `NEXT_PUBLIC_SITE_URL` 构建重定向 URL。如果未设置此变量它会回退到使用 `window.location.origin`,这在本地开发时会是 `localhost`
通过正确设置此变量,您可以确保无论在何处运行应用,邮件中的链接都能正确指向应用的实际位置。

50
README-google-auth.md Normal file
View File

@@ -0,0 +1,50 @@
# 配置 Google 登录功能
为了启用 Google 登录功能,您需要在 Supabase 和 Google Cloud Platform 进行配置。
## 步骤 1: 创建 Google OAuth 客户端
1. 访问 [Google Cloud Console](https://console.cloud.google.com/)
2. 创建一个新项目或选择现有项目
3. 在左侧菜单中导航到 "API 和服务" > "OAuth 同意屏幕"
4. 选择用户类型(外部或内部),然后点击"创建"
5. 填写必要的信息(应用名称、用户支持电子邮件等)并保存
6. 导航到 "API 和服务" > "凭据"
7. 点击"创建凭据" > "OAuth 客户端 ID"
8. 应用类型选择 "Web 应用"
9. 名称中输入您的应用名称
10. 添加以下已获授权的重定向 URI:
- `https://mwwvqwevplndzvmqmrxa.supabase.co/auth/v1/callback`
11. 点击"创建"
12. 复制生成的 "客户端 ID" 和 "客户端密钥"
## 步骤 2: 在 Supabase 中配置 Google 提供商
1. 登录 [Supabase 仪表板](https://app.supabase.com)
2. 选择您的项目
3. 导航到 "身份验证" > "提供商"
4. 找到 Google 提供商并启用它
5. 粘贴您刚才获取的 "客户端 ID" 和 "客户端密钥"
6. 保存配置
## 步骤 3: 更新重定向 URL如有需要
如果您的应用需要在登录后重定向到特定页面,请确保在 Google Cloud Console 和 Supabase 中配置了正确的重定向 URL。
在 Supabase 中:
1. 导航到 "身份验证" > "URL 配置"
2. 添加您的前端 URL 到站点 URL 字段中
3. 设置重定向 URL通常是您的前端 URL
## 测试
1. 在您的应用中,尝试使用 Google 登录
2. 验证认证流程,确保可以成功登录并重定向到应用
3. 检查 Supabase 中的用户数据,确认新用户已创建
## 故障排除
- 确保重定向 URI 完全匹配
- 确保 OAuth 同意屏幕已正确配置
- 查看 Supabase 和应用程序中的日志以获取详细的错误信息
- 如果遇到 CORS 错误,检查您的站点 URL 配置

View File

@@ -1,71 +1,65 @@
'use client';
import ProtectedRoute from '@/app/components/ProtectedRoute';
export default function HomePage() {
return (
<ProtectedRoute>
<div className="container mx-auto px-4 py-8">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-8">
Welcome to ShortURL Analytics
</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<a
href="/dashboard"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Dashboard
</h2>
<p className="text-gray-600">
Get an overview of all your short URL analytics data.
</p>
</a>
<a
href="/events"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Event Tracking
</h2>
<p className="text-gray-600">
View detailed events for all your short URLs.
</p>
</a>
<a
href="/analytics"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
URL Analysis
</h2>
<p className="text-gray-600">
Analyze performance of specific short URLs.
</p>
</a>
<a
href="/account"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Account Settings
</h2>
<p className="text-gray-600">
Manage your account and team settings.
</p>
</a>
</div>
<div className="container mx-auto px-4 py-8">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-8">
Welcome to ShortURL Analytics
</h1>
</div>
</ProtectedRoute>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<a
href="/dashboard"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Dashboard
</h2>
<p className="text-gray-600">
Get an overview of all your short URL analytics data.
</p>
</a>
<a
href="/events"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Event Tracking
</h2>
<p className="text-gray-600">
View detailed events for all your short URLs.
</p>
</a>
<a
href="/analytics"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
URL Analysis
</h2>
<p className="text-gray-600">
Analyze performance of specific short URLs.
</p>
</a>
<a
href="/account"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Account Settings
</h2>
<p className="text-gray-600">
Manage your account and team settings.
</p>
</a>
</div>
</div>
);
}

View File

@@ -14,7 +14,7 @@ import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
import { TagSelector } from '@/app/components/ui/TagSelector';
import { useSearchParams } from 'next/navigation';
import { useShortUrlStore } from '@/app/utils/store';
import ProtectedRoute from '@/app/components/ProtectedRoute';
import ClientRouteGuard from '@/app/components/ClientRouteGuard';
// 事件类型定义
interface Event {
@@ -1110,7 +1110,7 @@ function AnalyticsContent() {
// Main page component with Suspense
export default function AnalyticsPage() {
return (
<ProtectedRoute>
<ClientRouteGuard>
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
@@ -1118,6 +1118,6 @@ export default function AnalyticsPage() {
}>
<AnalyticsContent />
</Suspense>
</ProtectedRoute>
</ClientRouteGuard>
);
}

View File

@@ -1,126 +1,126 @@
# 数据分析活动接口说明
# Activities API Documentation
## 接口概述
`/api/activities` 端点提供了访问和导出分析事件数据的功能,可用于查询短链接的点击和访问记录。
## Overview
The Activities API provides event tracking data for short URLs. It allows retrieving visitor activity information based on various filters such as URL slug, domain, and date ranges.
## 请求方式
- HTTP 方法: **GET**
- URL: `/api/activities`
## Endpoint
```
GET /api/activities
```
## 请求参数
## Request Parameters
| 参数名 | 类型 | 必填 | 说明 |
|-------|------|------|------|
| slug | string | 否* | 短链接标识符 |
| domain | string | 否* | 短链接域名 |
| format | string | 否 | 响应格式,可选值: `csv`纯文本显示不传则默认返回JSON |
| startTime | string | 否* | 起始时间ISO格式 |
| endTime | string | 否* | 结束时间ISO格式 |
| page | number | 否 | 当前页码默认为1 |
| pageSize | number | 否 | 每页记录数默认为50 |
| Parameter | Type | Required | Description |
|------------|---------|----------|-------------|
| `slug` | string | No* | The short URL slug to filter events by |
| `domain` | string | No* | The domain to filter events by |
| `startTime`| string | No* | Start time for date range filter (ISO format) |
| `endTime` | string | No* | End time for date range filter (ISO format) |
| `page` | integer | No | Page number for pagination (default: 1) |
| `pageSize` | integer | No | Number of records per page (default: 50) |
| `format` | string | No | Response format, set to 'csv' for CSV output (default: JSON) |
_*注:必须提供 (slug和domain) 或 (startTimeendTime) 中的至少一组过滤条件_
\* Either `slug`+`domain` combination OR at least one of `startTime`/`endTime` must be provided.
## 响应格式
## Response Formats
### JSON格式(默认)
### JSON Format (Default)
JSON responses include the following structure:
```json
{
"success": true,
"data": [
{
"id": "事件ID",
"type": "事件类型",
"time": "事件时间",
"id": "event-id",
"type": "event-type",
"time": "timestamp",
"visitor": {
"id": "访问者ID",
"ipAddress": "IP地址",
"userAgent": "浏览器用户代理",
"referrer": "来源页面"
"id": "visitor-id",
"ipAddress": "ip-address",
"userAgent": "user-agent-string",
"referrer": "referrer-url"
},
"device": {
"type": "设备类型",
"browser": "浏览器",
"os": "操作系统"
"type": "device-type",
"browser": "browser-name",
"os": "operating-system"
},
"location": {
"country": "国家",
"city": "城市"
"country": "country-code",
"city": "city-name"
},
"link": {
"id": "链接ID",
"slug": "短链标识",
"originalUrl": "原始链接",
"label": "链接标签",
"tags": ["标签1", "标签2"]
"id": "link-id",
"slug": "link-slug",
"originalUrl": "original-url",
"label": "link-label",
"tags": ["tag1", "tag2"]
},
"utm": {
"source": "来源",
"medium": "媒介",
"campaign": "活动",
"term": "关键词",
"content": "内容"
"source": "utm-source",
"medium": "utm-medium",
"campaign": "utm-campaign",
"term": "utm-term",
"content": "utm-content"
}
}
],
"meta": {
"total": ,
"page": ,
"pageSize":
"total": 100,
"page": 1,
"pageSize": 50
}
}
```
### CSV格式
当使用 `format=csv` 参数时接口将返回以下列的CSV纯文本
```
time,activity,campaign,clientId,originPath
2023-01-01 12:34:56,click,spring_sale,abc123,https://example.com/path?utm_campaign=spring_sale
```
列说明:
- `time`: 事件发生时间
- `activity`: 事件类型(如点击、访问等)
- `campaign`: 营销活动标识从URL中的utm_campaign提取
- `clientId`: 访问者标识的前半部分
- `originPath`: 原始请求路径或引荐URL
## 使用示例
### 基本查询JSON
```
GET /api/activities?slug=0326recap10&domain=example.com
```
### 导出CSV
```
GET /api/activities?slug=0326recap10&format=csv
```
### 按日期范围过滤
```
GET /api/activities?startTime=2023-03-01T00:00:00Z&endTime=2023-03-31T23:59:59Z
```
### 分页
```
GET /api/activities?slug=0326recap10&page=2&pageSize=100
```
## 错误响应
当请求参数不正确或服务器发生错误时,返回以下格式:
In case of an error:
```json
{
"success": false,
"error": "错误描述信息"
"data": null,
"error": "Error message description"
}
```
常见错误代码:
- 400: 参数错误,例如缺少必要的过滤条件
- 500: 服务器内部错误
### CSV Format
When `format=csv` is specified, the response is returned as plain text in CSV format with the following columns:
- `time`: Timestamp of the event
- `activity`: Type of activity/event
- `campaign`: UTM campaign value (defaults to "demo" if not found)
- `clientId`: Visitor ID
- `originPath`: Original request path or referrer URL
## Examples
### Get activities for a specific short URL
```
GET /api/activities?slug=promo123&domain=googleads.link
```
### Get activities within a date range
```
GET /api/activities?startTime=2023-06-01T00:00:00Z&endTime=2023-06-30T23:59:59Z
```
### Get events as CSV
```
GET /api/activities?slug=promo123&domain=googleads.link&format=csv
```
### Pagination example
```
GET /api/activities?slug=promo123&domain=googleads.link&page=2&pageSize=20
```
## Error Codes
| Status Code | Description |
|-------------|-------------|
| 400 | Missing required parameters |
| 500 | Server error while processing the request |
## Notes
- For privacy and security reasons, some fields may be omitted or anonymized based on user settings.
- The CSV format is optimized for easy import into spreadsheet applications.
- When using the CSV format, the response is returned as plain text rather than a downloadable file.

View File

@@ -2,8 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { getEvents } from '@/lib/analytics';
import { ApiResponse } from '@/lib/types';
// 扩展Event类型以包含所需字段
interface EventWithFullPath extends Record<string, any> {
// Extended Event type with required fields
interface EventWithFullPath {
event_id?: string;
event_time?: string;
event_type?: string;
@@ -11,7 +11,18 @@ interface EventWithFullPath extends Record<string, any> {
ip_address?: string;
req_full_path?: string;
referrer?: string;
// 其他可能的字段
event_attributes?: string | Record<string, unknown>;
link_tags?: string | string[];
link_id?: string;
link_slug?: string;
link_original_url?: string;
link_label?: string;
device_type?: string;
browser?: string;
os?: string;
country?: string;
city?: string;
[key: string]: unknown;
}
export async function GET(request: NextRequest) {
@@ -27,12 +38,19 @@ export async function GET(request: NextRequest) {
const startTime = searchParams.get('startTime') || undefined;
const endTime = searchParams.get('endTime') || undefined;
// 修改验证逻辑,允许只使用时间范围
// 现在只需要确保有足够的过滤条件
// Check if either slug or domain is provided without the other
if ((slug && !domain) || (!slug && domain)) {
return NextResponse.json({
success: false,
error: 'Both slug and domain parameters must be provided together'
}, { status: 400 });
}
// Ensure either slug+domain or date range is provided
if ((!slug && !domain) && (!startTime && !endTime)) {
return NextResponse.json({
success: false,
error: 'Missing filter parameters: provide either slug/domain or date range'
error: 'Missing filter parameters: provide either slug+domain or date range'
}, { status: 400 });
}
@@ -89,7 +107,7 @@ export async function GET(request: NextRequest) {
// If utm_campaign is not found or URL parsing fails, use regex as fallback
const campaignMatch = url.match(/[?&]utm_campaign=([^&]+)/i);
if (campaignMatch && campaignMatch[1]) return campaignMatch[1];
} catch (_) {
} catch {
// If URL parsing fails, try regex directly
const campaignMatch = url.match(/[?&]utm_campaign=([^&]+)/i);
if (campaignMatch && campaignMatch[1]) return campaignMatch[1];
@@ -118,17 +136,19 @@ export async function GET(request: NextRequest) {
// Determine activity (event_type)
const activity = eventWithFullPath.event_type || '';
// Client ID (possibly part of visitor_id)
const clientId = eventWithFullPath.visitor_id?.split('-')[0] || 'undefined';
// 修改使用link_label替代visitor_id作为clientId
const clientId = eventWithFullPath.link_label || 'undefined';
// Original path (use full URL field)
const originPath = fullUrl || 'undefined';
// Original path - 修正使用link_original_url作为原始URL来源
const originPath = eventWithFullPath.link_original_url || 'undefined';
// Add to CSV content
csvContent += `${time},${activity},${campaign},${clientId},${originPath}\n`;
});
// Return CSV response
// No need to generate filename since we're not using Content-Disposition header
// Return CSV response without forcing download
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/plain'

View File

@@ -1,68 +1,18 @@
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get('code');
console.log('Auth callback received:', { url: request.url, hasCode: !!code });
// If no code parameter found, redirect to login page
if (!code) {
console.log('No code parameter found, redirecting to login page');
return NextResponse.redirect(new URL('/login', request.url));
if (code) {
const cookieStore = cookies();
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
await supabase.auth.exchangeCodeForSession(code);
}
try {
// Create Supabase client
const cookieStore = cookies();
const supabaseRouteHandler = createRouteHandlerClient({ cookies: () => cookieStore });
// Exchange code for session
console.log('Starting code exchange for session');
const { data, error } = await supabaseRouteHandler.auth.exchangeCodeForSession(code);
if (error) {
console.error('Error exchanging code for session:', error);
throw error;
}
console.log('Successfully retrieved session, user:', data.session?.user.email);
// Check if session was successfully created
if (data.session) {
console.log('Session created successfully:', {
userId: data.session.user.id,
email: data.session.user.email,
expiresAt: data.session.expires_at ? new Date(data.session.expires_at * 1000).toISOString() : 'unknown'
});
// Set additional cookie to ensure client can detect login status
// Use Next.js Response to set cookie
const response = NextResponse.redirect(new URL('/', request.url));
response.cookies.set({
name: 'sb-auth-token',
value: 'true',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
httpOnly: false,
});
console.log('Set backup cookie: sb-auth-token');
return response;
}
// Redirect to home page by default
console.log('Redirecting to home page');
return NextResponse.redirect(new URL('/', request.url));
} catch (error) {
console.error('Auth callback error:', error);
// Redirect to login page on error
return NextResponse.redirect(
new URL('/login?message=Authentication failed. Please try again.', request.url)
);
}
// URL to redirect to after sign in process completes
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://main.upj.to';
return NextResponse.redirect(new URL('/analytics', siteUrl));
}

View File

@@ -0,0 +1,45 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
// 这个组件会检查 localStorage 中是否有认证令牌,如果没有则重定向到登录页面
export default function ClientRouteGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 检查 localStorage 中是否有认证令牌
const checkAuth = () => {
// 查找 Supabase 认证令牌
const hasAuthToken = !!localStorage.getItem('sb-mwwvqwevplndzvmqmrxa-auth-token') ||
!!localStorage.getItem('sb-auth-token');
if (!hasAuthToken) {
// 如果没有令牌,重定向到登录页面
router.push('/login');
} else {
setIsAuthenticated(true);
}
setIsLoading(false);
};
checkAuth();
}, [router]);
// 显示加载状态
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-lg text-gray-700 dark:text-gray-300">...</p>
</div>
</div>
);
}
// 只有当用户已认证时才显示子组件
return isAuthenticated ? <>{children}</> : null;
}

View File

@@ -1,42 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, isLoading } = useAuth();
useEffect(() => {
// 如果非加载状态且用户未登录,重定向到登录页
if (!isLoading && !user) {
console.log('ProtectedRoute: 未登录,重定向到登录页');
// 保存当前URL以便登录后可以返回
const currentUrl = window.location.href;
router.push(`/login?redirect=${encodeURIComponent(currentUrl)}`);
}
}, [isLoading, user, router]);
// 如果正在加载,显示加载指示器
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-lg text-gray-700 dark:text-gray-300">...</p>
</div>
</div>
);
}
// 如果用户未登录,不渲染任何内容(等待重定向)
if (!user) {
console.log('ProtectedRoute: 用户未登录,等待重定向');
return null;
}
// 用户已登录,渲染子组件
console.log('ProtectedRoute: 用户已登录,渲染内容');
return <>{children}</>;
}

View File

@@ -99,42 +99,32 @@ export function ProjectSelector({
setLoading(true);
setError(null);
console.log(`开始获取项目数据用户ID: ${userId}, 团队ID过滤: ${effectiveTeamIds?.join(', ') || '无'}`);
try {
const supabase = getSupabaseClient();
console.log('Supabase客户端已创建准备获取项目数据');
if (effectiveTeamIds && effectiveTeamIds.length > 0) {
// If team IDs are provided, get projects for those teams
console.log(`通过团队ID获取项目: ${effectiveTeamIds.join(', ')}`);
const { data: projectsData, error: projectsError } = await supabase
.from('team_projects')
.select('project_id, projects:project_id(*), teams:team_id(name)')
.in('team_id', effectiveTeamIds)
.is('projects.deleted_at', null);
console.log(`团队项目查询结果:`, projectsData ? `找到${projectsData.length}` : '无数据',
projectsError ? `错误: ${projectsError.message}` : '无错误');
if (projectsError) throw projectsError;
if (!projectsData || projectsData.length === 0) {
console.log('未找到团队项目,返回空列表');
if (isMounted) setProjects([]);
return;
}
// Extract projects from response with team info
if (isMounted) {
console.log('处理团队项目数据');
const projectList: Project[] = [];
for (const item of projectsData as unknown as ProjectWithTeam[]) {
for (const item of projectsData as ProjectWithTeam[]) {
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
const project = item.projects as Project;
if (item.teams && typeof item.teams === 'object' && 'name' in item.teams) {
if (item.teams && 'name' in item.teams) {
project.team_name = item.teams.name;
}
// Avoid duplicate projects from different teams
@@ -144,42 +134,32 @@ export function ProjectSelector({
}
}
console.log(`处理后的项目数据: ${projectList.length}`);
setProjects(projectList);
}
} else {
// If no team IDs, get all user's projects
console.log(`获取用户所有项目用户ID: ${userId}`);
const { data: projectsData, error: projectsError } = await supabase
.from('user_projects')
.select('project_id, projects:project_id(*)')
.eq('user_id', userId)
.is('projects.deleted_at', null);
console.log(`用户项目查询结果:`, projectsData ? `找到${projectsData.length}` : '无数据',
projectsError ? `错误: ${projectsError.message}` : '无错误');
if (projectsError) throw projectsError;
if (!projectsData || projectsData.length === 0) {
console.log('未找到用户项目,返回空列表');
if (isMounted) setProjects([]);
return;
}
// Fetch team info for these projects
const projectIds = projectsData.map(item => item.project_id);
console.log(`获取项目的团队信息项目IDs: ${projectIds.join(', ')}`);
// Get team info for each project
const { data: teamProjectsData, error: teamProjectsError } = await supabase
.from('team_projects')
.select('project_id, teams:team_id(name)')
.in('project_id', projectIds);
console.log(`项目团队关系查询结果:`, teamProjectsData ? `找到${teamProjectsData.length}` : '无数据');
if (teamProjectsError) throw teamProjectsError;
// Create project ID to team name mapping
@@ -194,7 +174,6 @@ export function ProjectSelector({
// Extract projects with team names
if (isMounted && projectsData) {
console.log('处理用户项目数据');
const projectList: Project[] = [];
for (const item of projectsData) {
@@ -205,14 +184,12 @@ export function ProjectSelector({
}
}
console.log(`处理后的项目数据: ${projectList.length}`);
setProjects(projectList);
}
}
} catch (err) {
console.error('获取项目数据出错:', err);
if (isMounted) {
setError(err instanceof Error ? err.message : '获取项目数据失败');
setError(err instanceof Error ? err.message : 'Failed to load projects');
}
} finally {
if (isMounted) {
@@ -221,51 +198,27 @@ export function ProjectSelector({
}
};
// 获取Supabase客户端实例并订阅认证状态变化
try {
const supabase = getSupabaseClient();
console.log('注册项目选择器认证状态变化监听器');
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
console.log(`项目选择器认证状态变化: ${event}`, session ? `用户ID: ${session.user.id}` : '无会话');
if (event === 'SIGNED_IN' && session?.user?.id) {
fetchProjects(session.user.id);
} else if (event === 'SIGNED_OUT') {
setProjects([]);
setError(null);
}
});
const supabase = getSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
if (event === 'SIGNED_IN' && session?.user?.id) {
fetchProjects(session.user.id);
} else if (event === 'SIGNED_OUT') {
setProjects([]);
setError(null);
}
});
// 初始化时获取当前会话
console.log('项目选择器获取当前会话状态');
supabase.auth.getSession().then(({ data: { session } }) => {
console.log('项目选择器当前会话状态:', session ? `用户已登录ID: ${session.user.id}` : '用户未登录');
if (session?.user?.id) {
fetchProjects(session.user.id);
} else {
// 如果没有会话但组件需要初始化,可以设置加载完成
setLoading(false);
}
}).catch(err => {
console.error('项目选择器获取会话状态失败:', err);
// 确保即使获取会话失败也停止加载状态
setLoading(false);
});
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.user?.id) {
fetchProjects(session.user.id);
}
});
return () => {
console.log('ProjectSelector组件卸载清理订阅');
isMounted = false;
subscription.unsubscribe();
};
} catch (initError) {
console.error('初始化ProjectSelector出错:', initError);
// 确保在初始化出错时也停止加载状态
setLoading(false);
setError('初始化失败,请刷新页面重试');
return () => {
isMounted = false;
};
}
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, [effectiveTeamIds]);
const handleToggle = () => {

View File

@@ -123,48 +123,31 @@ export function TagSelector({
setLoading(true);
setError(null);
console.log(`开始获取标签数据, 团队ID过滤: ${effectiveTeamIds?.join(', ') || '无'}`);
try {
const supabase = getSupabaseClient();
console.log('Supabase客户端已创建准备获取标签数据');
let query = supabase.from('tags').select('*').is('deleted_at', null);
// Filter by team if teamId is provided
if (effectiveTeamIds) {
console.log(`通过团队ID过滤标签: ${effectiveTeamIds.join(', ')}`);
query = query.in('team_id', effectiveTeamIds);
}
const { data: tagsData, error: tagsError } = await query;
console.log(`标签查询结果:`, tagsData ? `找到${tagsData.length}` : '无数据',
tagsError ? `错误: ${tagsError.message}` : '无错误');
if (tagsError) throw tagsError;
if (!tagsData || tagsData.length === 0) {
console.log('未找到标签,返回空列表');
if (isMounted) setTags([]);
return;
}
if (isMounted) {
console.log(`设置${tagsData.length}个标签数据`);
setTags(tagsData as Tag[]);
// 如果已有value但tags刚加载好重新设置selectedIds
if (value && tagsData.length > 0) {
const ids = nameToId(value);
console.log(`根据名称设置选中的标签ID: ${ids.join(', ')}`);
setSelectedIds(ids);
}
}
} catch (err) {
console.error('获取标签数据出错:', err);
if (isMounted) {
setError(err instanceof Error ? err.message : '获取标签数据失败');
setError(err instanceof Error ? err.message : 'Failed to load tags');
}
} finally {
if (isMounted) {
@@ -173,40 +156,26 @@ export function TagSelector({
}
};
// 获取Supabase客户端实例并订阅认证状态变化
try {
const supabase = getSupabaseClient();
console.log('注册标签选择器认证状态变化监听器');
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => {
console.log(`标签选择器认证状态变化: ${event}`);
if (event === 'SIGNED_IN') {
fetchTags();
} else if (event === 'SIGNED_OUT') {
setTags([]);
setError(null);
}
});
const supabase = getSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => {
if (event === 'SIGNED_IN') {
fetchTags();
} else if (event === 'SIGNED_OUT') {
setTags([]);
setError(null);
}
});
// 初始化时获取标签数据
console.log('标签选择器初始化,获取标签数据');
supabase.auth.getSession().then(() => {
fetchTags();
});
return () => {
console.log('TagSelector组件卸载清理订阅');
isMounted = false;
subscription.unsubscribe();
};
} catch (initError) {
console.error('初始化TagSelector出错:', initError);
// 确保在初始化出错时也停止加载状态
setLoading(false);
setError('初始化失败,请刷新页面重试');
return () => {
isMounted = false;
};
}
}, [effectiveTeamIds, nameToId, value]);
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, [effectiveTeamIds]);
const handleToggle = () => {
if (!loading && !error && tags.length > 0) {

View File

@@ -68,98 +68,44 @@ export function TeamSelector({
setLoading(true);
setError(null);
console.log(`开始获取团队数据用户ID: ${userId}`);
try {
const supabase = getSupabaseClient();
console.log('Supabase客户端已创建准备获取团队数据');
// 尝试直接获取团队数据不等待create-default
// 尝试创建默认团队和项目(如果用户还没有)
try {
const { data: memberships, error: membershipError } = await supabase
.from('team_membership')
.select('team_id')
.eq('user_id', userId);
const response = await limqRequest('team/create-default', 'POST');
console.log('Default team creation response:', response);
} catch (teamError) {
console.error('Error creating default team:', teamError);
}
const { data: memberships, error: membershipError } = await supabase
.from('team_membership')
.select('team_id')
.eq('user_id', userId);
console.log(`团队成员关系查询结果:`, memberships ? `找到${memberships.length}` : '无数据', membershipError ? `错误: ${membershipError.message}` : '无错误');
if (membershipError) throw membershipError;
if (membershipError) throw membershipError;
if (!memberships || memberships.length === 0) {
if (isMounted) setTeams([]);
return;
}
if (!memberships || memberships.length === 0) {
console.log('未找到团队成员关系,尝试创建默认团队');
// 尝试创建默认团队和项目(如果用户还没有)
try {
const response = await limqRequest('team/create-default', 'POST');
console.log('默认团队创建成功:', response);
// 创建默认团队后重新获取团队列表
const { data: refreshedMemberships, error: refreshError } = await supabase
.from('team_membership')
.select('team_id')
.eq('user_id', userId);
console.log(`刷新后的团队成员关系:`, refreshedMemberships ? `找到${refreshedMemberships.length}` : '无数据');
if (refreshError) throw refreshError;
if (!refreshedMemberships || refreshedMemberships.length === 0) {
if (isMounted) {
console.log('创建默认团队后仍未找到团队,设置空团队列表');
setTeams([]);
}
return;
}
const teamIds = refreshedMemberships.map(m => m.team_id);
console.log('获取到团队IDs:', teamIds);
const { data: teamsData, error: teamsError } = await supabase
.from('teams')
.select('*')
.in('id', teamIds)
.is('deleted_at', null);
console.log(`团队数据查询结果:`, teamsData ? `找到${teamsData.length}` : '无数据');
const teamIds = memberships.map(m => m.team_id);
const { data: teamsData, error: teamsError } = await supabase
.from('teams')
.select('*')
.in('id', teamIds)
.is('deleted_at', null);
if (teamsError) throw teamsError;
if (teamsError) throw teamsError;
if (isMounted && teamsData) {
setTeams(teamsData);
}
return;
} catch (teamError) {
console.error('创建默认团队失败:', teamError);
// 创建失败也继续,返回空列表
if (isMounted) setTeams([]);
return;
}
}
const teamIds = memberships.map(m => m.team_id);
console.log('获取到团队IDs:', teamIds);
const { data: teamsData, error: teamsError } = await supabase
.from('teams')
.select('*')
.in('id', teamIds)
.is('deleted_at', null);
console.log(`团队数据查询结果:`, teamsData ? `找到${teamsData.length}` : '无数据');
if (teamsError) throw teamsError;
if (isMounted && teamsData) {
setTeams(teamsData);
}
} catch (dataError) {
console.error('获取团队数据失败:', dataError);
throw dataError;
if (isMounted && teamsData) {
setTeams(teamsData);
}
} catch (err) {
console.error('获取团队数据出错:', err);
if (isMounted) {
setError(err instanceof Error ? err.message : '获取团队数据失败');
setError(err instanceof Error ? err.message : 'Failed to load teams');
}
} finally {
if (isMounted) {
@@ -168,51 +114,27 @@ export function TeamSelector({
}
};
// 获取Supabase客户端实例并订阅认证状态变化
try {
const supabase = getSupabaseClient();
console.log('注册认证状态变化监听器');
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
console.log(`认证状态变化: ${event}`, session ? `用户ID: ${session.user.id}` : '无会话');
if (event === 'SIGNED_IN' && session?.user?.id) {
fetchTeams(session.user.id);
} else if (event === 'SIGNED_OUT') {
setTeams([]);
setError(null);
}
});
const supabase = getSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
if (event === 'SIGNED_IN' && session?.user?.id) {
fetchTeams(session.user.id);
} else if (event === 'SIGNED_OUT') {
setTeams([]);
setError(null);
}
});
// 初始化时获取当前会话
console.log('获取当前会话状态');
supabase.auth.getSession().then(({ data: { session } }) => {
console.log('当前会话状态:', session ? `用户已登录ID: ${session.user.id}` : '用户未登录');
if (session?.user?.id) {
fetchTeams(session.user.id);
} else {
// 如果没有会话但组件需要初始化,可以设置加载完成
setLoading(false);
}
}).catch(err => {
console.error('获取会话状态失败:', err);
// 确保即使获取会话失败也停止加载状态
setLoading(false);
});
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.user?.id) {
fetchTeams(session.user.id);
}
});
return () => {
console.log('TeamSelector组件卸载清理订阅');
isMounted = false;
subscription.unsubscribe();
};
} catch (initError) {
console.error('初始化TeamSelector出错:', initError);
// 确保在初始化出错时也停止加载状态
setLoading(false);
setError('初始化失败,请刷新页面重试');
return () => {
isMounted = false;
};
}
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, []);
const handleToggle = () => {

View File

@@ -3,10 +3,10 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import ProtectedRoute from '@/app/components/ProtectedRoute';
import { limqRequest } from '@/lib/api';
import { TeamSelector } from '@/app/components/ui/TeamSelector';
import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
import ClientRouteGuard from '@/app/components/ClientRouteGuard';
interface ShortUrlData {
originalUrl: string;
@@ -21,9 +21,9 @@ interface ShortUrlData {
export default function CreateShortUrlPage() {
return (
<ProtectedRoute>
<ClientRouteGuard>
<CreateShortUrlForm />
</ProtectedRoute>
</ClientRouteGuard>
);
}
@@ -47,7 +47,7 @@ function CreateShortUrlForm() {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Use useEffect to add user information to form data when loading
// Use useEffect to add user information to form data on load
useEffect(() => {
if (user) {
console.log('Current user:', user.email);
@@ -114,7 +114,7 @@ function CreateShortUrlForm() {
throw new Error('Domain is required');
}
// Build request data according to API requirements
// Construct request data according to API requirements
const requestData = {
type: "shorturl",
attributes: {
@@ -137,7 +137,7 @@ function CreateShortUrlForm() {
// Call API to create shorturl resource
const response = await limqRequest('resource/shorturl', 'POST', requestData as unknown as Record<string, unknown>);
console.log('Creation successful:', response);
console.log('Created successfully:', response);
setSuccess(true);
// Redirect to links list page after 2 seconds
@@ -207,7 +207,7 @@ function CreateShortUrlForm() {
name="title"
value={formData.title}
onChange={handleChange}
placeholder="e.g.: Product Launch Campaign"
placeholder="e.g., Product Launch Campaign"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
required
/>
@@ -265,7 +265,7 @@ function CreateShortUrlForm() {
name="domain"
value={formData.domain}
onChange={handleChange}
placeholder="e.g.: googleads.link"
placeholder="e.g., googleads.link"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
required
/>

View File

@@ -1,168 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/lib/auth';
import supabase from '@/lib/supabase';
import { Session, User } from '@supabase/supabase-js';
interface SessionData {
session: Session | null;
user?: User | null;
}
export default function DebugPage() {
const { user, session, isLoading } = useAuth();
const [cookies, setCookies] = useState<Record<string, string>>({});
const [rawCookies, setRawCookies] = useState('');
const [sessionData, setSessionData] = useState<SessionData | null>(null);
const [redirectTarget, setRedirectTarget] = useState('/analytics');
useEffect(() => {
// Get all cookies
const allCookies = document.cookie.split(';').reduce((acc, cookie) => {
const [key, value] = cookie.trim().split('=');
if (key) acc[key] = value || '';
return acc;
}, {} as Record<string, string>);
setCookies(allCookies);
setRawCookies(document.cookie);
// Test Supabase session
const testSession = async () => {
try {
console.log('Getting Supabase session');
const { data, error } = await supabase.auth.getSession();
console.log('Supabase session result:', { data, error });
if (error) {
console.error('Session error:', error);
} else {
setSessionData(data);
}
} catch (err) {
console.error('Error getting session:', err);
}
};
testSession();
}, []);
const refreshSession = async () => {
try {
console.log('Manually refreshing session');
const { data, error } = await supabase.auth.refreshSession();
console.log('Refresh result:', { data, error });
alert('Session refresh complete, please check console logs');
if (!error && data.session) {
window.location.reload();
}
} catch (err) {
console.error('Error refreshing session:', err);
alert('Error refreshing session: ' + String(err));
}
};
const forceRedirect = () => {
if (redirectTarget) {
window.location.href = redirectTarget;
}
};
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Authentication Debug Page</h1>
<div className="bg-gray-100 p-6 rounded-lg mb-6">
<h2 className="text-xl font-semibold mb-4">User Status</h2>
<div className="space-y-2">
<p>Loading status: {isLoading ? 'Loading...' : 'Loaded'}</p>
<p>Logged in: {user ? 'Yes' : 'No'}</p>
<p>User email: {user?.email || 'Not logged in'}</p>
<p>User ID: {user?.id || 'Not logged in'}</p>
<p>Session valid: {session ? 'Yes' : 'No'}</p>
<p>Session expires: {session?.expires_at ? new Date(session.expires_at * 1000).toLocaleString() : 'No session'}</p>
</div>
</div>
<div className="bg-gray-100 p-6 rounded-lg mb-6">
<h2 className="text-xl font-semibold mb-4">Supabase Session Data</h2>
<pre className="bg-gray-200 p-4 rounded text-xs overflow-auto max-h-60">
{sessionData ? JSON.stringify(sessionData, null, 2) : 'Loading...'}
</pre>
<button
onClick={refreshSession}
className="mt-4 px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Refresh Session
</button>
</div>
<div className="bg-gray-100 p-6 rounded-lg mb-6">
<h2 className="text-xl font-semibold mb-4">Cookie Information</h2>
<div className="space-y-2">
<p className="text-sm mb-2">Raw cookie string:</p>
<pre className="bg-gray-200 p-4 rounded overflow-x-auto text-xs">
{rawCookies || '(empty)'}
</pre>
<p className="text-sm mt-4 mb-2">Parsed cookies:</p>
<pre className="bg-gray-200 p-4 rounded overflow-x-auto text-xs">
{JSON.stringify(cookies, null, 2) || '{}'}
</pre>
<p className="text-sm mt-4 mb-2">Supabase-related cookies:</p>
<div className="space-y-1">
<p>sb-access-token: {cookies['sb-access-token'] ? 'Exists' : 'Not found'}</p>
<p>sb-refresh-token: {cookies['sb-refresh-token'] ? 'Exists' : 'Not found'}</p>
<p>supabase-auth-token: {cookies['supabase-auth-token'] ? 'Exists' : 'Not found'}</p>
</div>
</div>
</div>
<div className="bg-gray-100 p-6 rounded-lg mb-6">
<h2 className="text-xl font-semibold mb-4">Manual Redirect</h2>
<div className="flex space-x-2 items-center">
<input
type="text"
value={redirectTarget}
onChange={(e) => setRedirectTarget(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded"
placeholder="/analytics"
/>
<button
onClick={forceRedirect}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Force Redirect
</button>
</div>
</div>
<div className="flex space-x-4">
<button
onClick={() => window.location.href = '/login'}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Go to Login
</button>
<button
onClick={async () => {
try {
await supabase.auth.signOut();
alert('Signed out, refreshing page...');
window.location.reload();
} catch (err) {
alert('Error signing out: ' + String(err));
}
}}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Sign Out
</button>
</div>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import { Loader2, ExternalLink, Search } from 'lucide-react';
import { TeamSelector } from '@/app/components/ui/TeamSelector';
import { useRouter } from 'next/navigation';
import { useShortUrlStore, ShortUrlData } from '@/app/utils/store';
import ProtectedRoute from '@/app/components/ProtectedRoute';
import ClientRouteGuard from '@/app/components/ClientRouteGuard';
// Define attribute type to avoid using 'any'
interface LinkAttributes {
@@ -104,12 +104,9 @@ const convertClickHouseToShortLink = (data: Record<string, unknown>): ShortLink
export default function LinksPage() {
return (
<ProtectedRoute>
<div className="container p-6 mx-auto">
<h1 className="mb-6 text-2xl font-bold"></h1>
<LinksPageContent />
</div>
</ProtectedRoute>
<ClientRouteGuard>
<LinksPageContent />
</ClientRouteGuard>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/lib/auth';
@@ -20,43 +20,20 @@ function MessageHandler({ setMessage }: { setMessage: (message: { type: string,
}
export default function LoginPage() {
const searchParams = useSearchParams();
const router = useRouter();
const { signIn, signInWithGoogle, user } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: '', content: '' });
const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
// Get redirect URL
useEffect(() => {
if (searchParams) {
const redirect = searchParams.get('redirect');
if (redirect) {
setRedirectUrl(decodeURIComponent(redirect));
}
}
}, [searchParams]);
// If user is logged in, redirect to original page or home page
// 如果用户已登录,重定向到首页
useEffect(() => {
if (user) {
console.log('User is logged in, preparing to redirect', { redirectUrl });
// Add a short delay to ensure state updates are complete
setTimeout(() => {
if (redirectUrl) {
// Use hard redirect instead of router.push
console.log('Redirecting to original URL:', redirectUrl);
window.location.href = redirectUrl;
} else {
console.log('Redirecting to home page');
window.location.href = '/';
}
}, 100);
router.push('/');
}
}, [user, redirectUrl]);
}, [user, router]);
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault();
@@ -76,10 +53,10 @@ export default function LoginPage() {
const { error } = await signIn(email, password);
if (error) {
throw new Error(error instanceof Error ? error.message : 'Unknown error');
throw new Error(error.message);
}
// After successful login, redirect via useEffect
// 登录成功后,会通过 useEffect 重定向
} catch (error) {
console.error('Login error:', error);
setMessage({
@@ -98,10 +75,10 @@ export default function LoginPage() {
const { error } = await signInWithGoogle();
if (error) {
throw new Error(error instanceof Error ? error.message : 'Unknown error');
throw new Error(error.message);
}
// Google OAuth will redirect the user
// Google OAuth will handle the redirect
} catch (error) {
console.error('Google login error:', error);
setMessage({
@@ -146,7 +123,31 @@ export default function LoginPage() {
</div>
)}
<form onSubmit={handleEmailSignIn} className="mt-8 space-y-6">
{/* Google Sign In Button */}
<button
type="button"
onClick={handleGoogleSignIn}
disabled={isLoading}
className="w-full flex items-center justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="h-5 w-5 mr-2" viewBox="0 0 24 24" width="24" height="24">
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path fill="#4285F4" d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"/>
<path fill="#34A853" d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"/>
<path fill="#FBBC05" d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"/>
<path fill="#EA4335" d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"/>
</g>
</svg>
{isLoading ? 'Signing in...' : 'Sign in with Google'}
</button>
<div className="mt-6 flex items-center justify-center">
<div className="border-t border-gray-300 flex-grow mr-3"></div>
<span className="text-sm text-gray-500">or</span>
<div className="border-t border-gray-300 flex-grow ml-3"></div>
</div>
<form onSubmit={handleEmailSignIn} className="mt-6 space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
@@ -189,38 +190,11 @@ export default function LoginPage() {
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{isLoading ? 'Signing in...' : 'Sign in'}
{isLoading ? 'Signing in...' : 'Sign in with Email'}
</button>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6">
<button
onClick={handleGoogleSignIn}
disabled={isLoading}
className="w-full flex items-center justify-center gap-3 py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign in with Google
</button>
</div>
</div>
<p className="text-sm mt-6 text-gray-600">
Don&apos;t have an account?{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">

View File

@@ -1,39 +1,5 @@
'use client';
import { useEffect } from 'react';
import { useAuth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default function Home() {
const { user, isLoading } = useAuth();
// Add debug logs
console.log('Root page state:', { isLoading, userAuthenticated: !!user });
useEffect(() => {
if (!isLoading) {
console.log('Preparing to redirect from root page', { isLoggedIn: !!user });
// Use hard redirect to ensure full page refresh
if (user) {
console.log('User is logged in, redirecting to analytics page');
window.location.href = '/analytics';
} else {
console.log('User is not logged in, redirecting to login page');
window.location.href = '/login';
}
}
}, [isLoading, user]);
// Display loading indicator with status information
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-lg text-gray-700">Loading...</p>
<p className="mt-2 text-sm text-gray-500">
Status: {isLoading ? 'Checking login status' : (user ? 'Logged in' : 'Not logged in')}
</p>
</div>
</div>
);
redirect('/analytics');
}

View File

@@ -12,44 +12,44 @@ export default function RegisterPage() {
const [isLoading, setIsLoading] = useState(false);
const { signUp, signInWithGoogle } = useAuth();
// 处理注册表单提交
// Handle registration form submission
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
// 验证密码
// Validate passwords
if (password !== confirmPassword) {
setError('两次输入的密码不一致');
setError('Passwords do not match');
return;
}
// 密码强度验证
// Password strength validation
if (password.length < 6) {
setError('密码长度至少为6个字符');
setError('Password must be at least 6 characters');
return;
}
setIsLoading(true);
try {
await signUp(email, password);
// 注册成功后会跳转到登录页面,提示用户验证邮箱
// After successful registration, redirect to login page with email verification prompt
} catch (error) {
console.error('Registration error:', error);
setError('注册失败,请稍后再试或使用其他邮箱');
setError('Registration failed. Please try again later or use a different email');
} finally {
setIsLoading(false);
}
};
// 处理Google注册/登录
// Handle Google registration/login
const handleGoogleSignIn = async () => {
setError(null);
try {
await signInWithGoogle();
// 登录流程会重定向到Google然后回到应用
// Login flow will redirect to Google and then back to the application
} catch (error) {
console.error('Google sign in error:', error);
setError('Google登录失败,请稍后再试');
setError('Google login failed. Please try again later');
}
};
@@ -57,13 +57,13 @@ export default function RegisterPage() {
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
<div className="w-full max-w-md p-8 space-y-8 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"></h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Register</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
访
Create your account to access the analytics dashboard
</p>
</div>
{/* 错误提示 */}
{/* Error message */}
{error && (
<div className="p-4 mb-4 text-sm text-red-700 bg-red-100 dark:bg-red-900 dark:text-red-200 rounded-lg">
{error}
@@ -74,7 +74,7 @@ export default function RegisterPage() {
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email Address
</label>
<input
id="email"
@@ -90,7 +90,7 @@ export default function RegisterPage() {
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Password
</label>
<input
id="password"
@@ -106,7 +106,7 @@ export default function RegisterPage() {
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Confirm Password
</label>
<input
id="confirmPassword"
@@ -128,7 +128,7 @@ export default function RegisterPage() {
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '注册中...' : '注册'}
{isLoading ? 'Registering...' : 'Register'}
</button>
</div>
@@ -137,7 +137,7 @@ export default function RegisterPage() {
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400"></span>
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">or</span>
</div>
</div>
@@ -173,19 +173,19 @@ export default function RegisterPage() {
/>
</g>
</svg>
使Google账号注册
Sign up with Google
</button>
</div>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
{' '}
Already have an account?{' '}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
Log in
</Link>
</p>
</div>

View File

@@ -3,12 +3,11 @@ import type { Database } from "@/types/supabase";
let supabase: SupabaseClient<Database> | null = null;
// 增强的存储适配器使用localStorage并添加更多错误处理
// 简单的存储适配器使用localStorage
const storageAdapter = {
getItem: async (key: string) => {
try {
const item = localStorage.getItem(key);
console.log(`Storage get for key [${key}]: ${item ? "found" : "not found"}`);
return item;
} catch (error) {
console.error("Storage get error:", error);
@@ -19,7 +18,6 @@ const storageAdapter = {
setItem: async (key: string, value: string) => {
try {
localStorage.setItem(key, value);
console.log(`Storage set for key [${key}] successful`);
} catch (error) {
console.error("Storage set error:", error);
}
@@ -28,42 +26,18 @@ const storageAdapter = {
removeItem: async (key: string) => {
try {
localStorage.removeItem(key);
console.log(`Storage remove for key [${key}] successful`);
} catch (error) {
console.error("Storage remove error:", error);
}
},
};
// 添加一个函数来检查Supabase连接状态
export const checkSupabaseConnection = async (): Promise<boolean> => {
try {
const client = getSupabaseClient();
const { error } = await client.from('_health').select('*').limit(1);
if (error) {
console.error('Supabase connection check failed:', error);
return false;
}
console.log('Supabase connection check successful');
return true;
} catch (error) {
console.error('Supabase connection check exception:', error);
return false;
}
};
export const getSupabaseClient = (): SupabaseClient<Database> => {
if (!supabase) {
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
console.error('Missing Supabase environment variables');
throw new Error('Missing Supabase environment variables');
}
console.log('Creating new Supabase client with URL:', process.env.NEXT_PUBLIC_SUPABASE_URL);
// 使用as断言来避免类型错误
supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
@@ -73,27 +47,13 @@ export const getSupabaseClient = (): SupabaseClient<Database> => {
storage: storageAdapter,
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
}
) as SupabaseClient<Database>;
// 立即检查客户端创建后的会话状态
void supabase.auth.getSession().then(({ data: { session } }) => {
console.log('Initial session check:', session ? 'Session exists' : 'No session');
}).catch(err => {
console.error('Error checking initial session:', err);
});
);
}
if (!supabase) {
throw new Error('Failed to create Supabase client');
}
return supabase;
};
export const clearSupabaseInstance = () => {
console.log('Clearing Supabase instance');
supabase = null;
};

View File

@@ -8,132 +8,43 @@ export interface ApiResponse<T = unknown> {
message?: string;
}
/**
* 通用的LIMQ API请求函数包含重试机制和错误处理
*/
// Common function for authenticated API requests to LIMQ
export async function limqRequest<T = unknown>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
data?: Record<string, unknown>,
options?: {
retryCount?: number;
retryDelay?: number;
timeout?: number;
}
data?: Record<string, unknown>
): Promise<ApiResponse<T>> {
// 默认配置
const retryCount = options?.retryCount ?? 2; // 默认重试2次
const retryDelay = options?.retryDelay ?? 1000; // 默认延迟1秒
const timeout = options?.timeout ?? 10000; // 默认超时10秒
// Get current session
const { data: { session } } = await supabase.auth.getSession();
let lastError: Error | null = null;
let currentRetry = 0;
// 创建延迟函数
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// 重试循环
while (currentRetry <= retryCount) {
try {
console.log(`[API] ${method} ${endpoint} 尝试 ${currentRetry + 1}/${retryCount + 1}`);
// 获取会话
const { data: { session } } = await supabase.auth.getSession();
// 检查会话是否存在
if (!session) {
console.error(`[API] 未找到活跃会话,用户需要登录`);
if (currentRetry < retryCount) {
currentRetry++;
console.log(`[API] 等待 ${retryDelay}ms 后重试获取会话...`);
await delay(retryDelay);
continue;
}
return {
success: false,
error: '需要登录才能访问API'
};
}
// 获取API基础URL
const baseUrl = process.env.NEXT_PUBLIC_LIMQ_API;
if (!baseUrl) {
throw new Error('API URL未配置');
}
const url = `${baseUrl}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`;
console.log(`[API] 请求URL: ${url}`);
// 构建请求选项
const fetchOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`
},
mode: 'cors'
};
if (data && (method === 'POST' || method === 'PUT')) {
fetchOptions.body = JSON.stringify(data);
console.log(`[API] 请求数据:`, data);
}
// 添加超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
fetchOptions.signal = controller.signal;
// 发送请求
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId); // 清除超时控制
// 处理响应
if (!response.ok) {
const errorData = await response.json().catch(() => null);
console.error(`[API] 请求失败: ${response.status} ${response.statusText}`, errorData);
// 对于认证错误,尝试重试
if ((response.status === 401 || response.status === 403) && currentRetry < retryCount) {
currentRetry++;
console.log(`[API] 认证错误,等待 ${retryDelay}ms 后重试...`);
await delay(retryDelay);
continue;
}
throw new Error(errorData?.error || `请求失败: ${response.status}`);
}
// 成功响应
const responseData = await response.json();
console.log(`[API] ${method} ${endpoint} 成功`);
return responseData;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`[API] 请求出错:`, lastError);
// 对于超时和网络错误,尝试重试
if (currentRetry < retryCount &&
(error instanceof DOMException && error.name === 'AbortError' ||
error instanceof TypeError && error.message.includes('network'))) {
currentRetry++;
console.log(`[API] 网络错误,等待 ${retryDelay}ms 后重试...`);
await delay(retryDelay);
continue;
}
// 已达到最大重试次数或不是网络错误
break;
}
if (!session) {
throw new Error('No active session. User must be authenticated.');
}
// 所有重试均失败
console.error(`[API] ${method} ${endpoint} 失败,已重试 ${currentRetry}`);
return {
success: false,
error: lastError?.message || '请求失败,请稍后重试'
const baseUrl = process.env.NEXT_PUBLIC_LIMQ_API;
const url = `${baseUrl}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`;
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`
},
mode: 'cors'
};
if (data && (method === 'POST' || method === 'PUT')) {
options.body = JSON.stringify(data);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.error || `Request failed with status ${response.status}`
);
}
return response.json();
}

View File

@@ -4,11 +4,12 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Session, User } from '@supabase/supabase-js';
import supabase from './supabase';
import { limqRequest } from './api';
// Define user type
// 定义用户类型
export type AuthUser = User | null;
// Define auth context type
// 定义验证上下文类型
export type AuthContextType = {
user: AuthUser;
session: Session | null;
@@ -20,22 +21,22 @@ export type AuthContextType = {
signOut: () => Promise<void>;
};
// Create auth context
// 创建验证上下文
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Auth provider component
// 验证提供者组件
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUser>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// Initialize auth state
// 初始化验证状态
useEffect(() => {
const getSession = async () => {
setIsLoading(true);
try {
// Try to get session from Supabase
// 尝试从Supabase获取会话
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
@@ -43,15 +44,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return;
}
// Print session info for debugging
console.log('Supabase session loaded:', session ? 'Found' : 'Not found');
if (session) {
console.log('User authenticated:', session.user.email);
if (session.expires_at) {
console.log('Session expires at:', new Date(session.expires_at * 1000).toLocaleString());
}
}
setSession(session);
setUser(session?.user || null);
} catch (error) {
@@ -63,98 +55,89 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
getSession();
// Listen for auth state changes
// 监听验证状态变化
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
console.log('Auth state changed, event:', _event);
console.log('New session:', session ? 'Valid' : 'None');
setSession(session);
setUser(session?.user || null);
});
// Cleanup function
// 清理函数
return () => {
subscription.unsubscribe();
};
}, []);
// Sign in function
// 登录函数
const signIn = async (email: string, password: string) => {
setIsLoading(true);
try {
console.log('Attempting to sign in:', { email });
console.log('尝试登录:', { email });
// Try to sign in with Supabase
// 尝试通过Supabase登录
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
console.error('Sign in error:', error);
console.error('登录出错:', error);
return { error };
}
// Sign in successful, set session and user info
console.log('Sign in successful, user:', data.user?.email);
setSession(data.session);
setUser(data.user);
// Use hard redirect instead of router.push to ensure full page refresh
console.log('Preparing to redirect to analytics page');
// Add short delay to ensure state is updated, then redirect
setTimeout(() => {
window.location.href = '/analytics';
}, 100);
router.push('/analytics');
return {};
} catch (error) {
console.error('Error during sign in process:', error);
console.error('登录过程出错:', error);
return { error };
} finally {
setIsLoading(false);
}
};
// Google sign in function
// Google登录函数
const signInWithGoogle = async () => {
setIsLoading(true);
try {
// Try to sign in with Google via Supabase
// 获取网站 URL如果环境变量不存在则使用当前来源
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || window.location.origin;
// 尝试通过Supabase登录Google
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline',
prompt: 'consent',
}
redirectTo: `${siteUrl}/auth/callback`,
},
});
if (error) {
console.error('Google sign in error:', error);
console.error('Google登录出错:', error);
return { error };
}
return {}; // Return empty object when successful
} catch (error) {
console.error('Error during Google sign in process:', error);
console.error('Google登录过程出错:', error);
return { error };
} finally {
setIsLoading(false);
}
};
// GitHub sign in function
// GitHub登录函数
const signInWithGitHub = async () => {
setIsLoading(true);
try {
// Try to sign in with GitHub via Supabase
// 获取网站 URL如果环境变量不存在则使用当前来源
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || window.location.origin;
// 尝试通过Supabase登录GitHub
const { error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
redirectTo: `${siteUrl}/auth/callback`,
},
});
@@ -172,49 +155,52 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};
// Sign up function
// 注册函数
const signUp = async (email: string, password: string) => {
setIsLoading(true);
try {
// Try to sign up via Supabase
// 获取网站 URL如果环境变量不存在则使用当前来源
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || window.location.origin;
// 尝试通过Supabase注册
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
emailRedirectTo: `${siteUrl}/auth/callback`,
}
});
if (error) {
console.error('Sign up error:', error);
console.error('注册出错:', error);
throw error;
}
// After successful registration, redirect to login page with confirmation message
// 注册成功后跳转到登录页面并显示确认消息
router.push('/login?message=Registration successful! Please check your email to verify your account before logging in.');
} catch (error) {
console.error('Error during sign up process:', error);
console.error('注册过程出错:', error);
throw error;
} finally {
setIsLoading(false);
}
};
// Sign out function
// 登出函数
const signOut = async () => {
setIsLoading(true);
try {
// Try to sign out via Supabase
// 尝试通过Supabase登出
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Sign out error:', error);
console.error('登出出错:', error);
throw error;
}
setSession(null);
setUser(null);
router.push('/login');
} catch (error) {
console.error('Error during sign out process:', error);
console.error('登出过程出错:', error);
throw error;
} finally {
setIsLoading(false);
@@ -239,7 +225,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
);
};
// Custom hook
// 自定义钩子
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
@@ -248,7 +234,7 @@ export const useAuth = () => {
return context;
};
// Protected route component
// 受保护路由组件
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isLoading } = useAuth();
const router = useRouter();
@@ -264,7 +250,7 @@ export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ childr
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-lg text-gray-700 dark:text-gray-300">Loading...</p>
<p className="mt-4 text-lg text-gray-700 dark:text-gray-300">...</p>
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import { createClient } from '@supabase/supabase-js';
// Get Supabase configuration from environment variables
// 从环境变量获取Supabase配置
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || '';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || '';
@@ -8,7 +8,7 @@ console.log('Supabase Configuration Check:', {
urlDefined: !!supabaseUrl,
keyDefined: !!supabaseAnonKey,
url: supabaseUrl,
// Print partial key for debugging
// 打印部分key以便调试
keyPrefix: supabaseAnonKey ? supabaseAnonKey.substring(0, 20) + '...' : 'undefined',
keyLength: supabaseAnonKey ? supabaseAnonKey.length : 0
});
@@ -17,7 +17,7 @@ if (!supabaseUrl || !supabaseAnonKey) {
console.error('Supabase URL and Anon Key are required');
}
// Try to decode JWT token and print decoded content
// 尝试解码JWT token并打印解码内容
try {
if (supabaseAnonKey) {
const parts = supabaseAnonKey.split('.');
@@ -30,54 +30,19 @@ try {
}
}
} catch (error) {
console.error('JWT decoding failed:', error);
console.error('JWT解码失败:', error);
}
// Create custom cookie handling logic
const customStorage = {
getItem: (key: string): string | null => {
if (typeof document === 'undefined') return null;
const cookie = document.cookie
.split(';')
.find((c) => c.trim().startsWith(`${key}=`));
return cookie ? cookie.split('=')[1] : null;
},
setItem: (key: string, value: string): void => {
if (typeof document === 'undefined') return;
// Get current host and port to handle different ports on localhost
const host = typeof window !== 'undefined' ? window.location.hostname : '';
// Set cookie, using generic domain for localhost
document.cookie = `${key}=${value}; path=/; max-age=${60 * 60 * 24 * 7}; samesite=lax; domain=${host}`;
console.log(`Cookie ${key} has been set, domain=${host}`);
},
removeItem: (key: string): void => {
if (typeof document === 'undefined') return;
// Get current host and port to handle different ports on localhost
const host = typeof window !== 'undefined' ? window.location.hostname : '';
// Remove cookie, using generic domain for localhost
document.cookie = `${key}=; path=/; max-age=0; samesite=lax; domain=${host}`;
console.log(`Cookie ${key} has been removed`);
},
};
// Create Supabase client
// 创建Supabase客户端
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
storageKey: 'sb-auth-token',
storage: customStorage
}
});
// Test Supabase connection
// 测试Supabase连接
supabase.auth.onAuthStateChange((event, session) => {
console.log(`Supabase auth event: ${event}`, session ? 'Session exists' : 'No session');
if (session) {
@@ -85,7 +50,7 @@ supabase.auth.onAuthStateChange((event, session) => {
}
});
// Try to perform health check
// 尝试执行健康检查
async function checkSupabaseHealth() {
try {
const { data, error } = await supabase.from('_health').select('*').limit(1);

View File

@@ -1,80 +1,22 @@
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Get the request path
const path = request.nextUrl.pathname;
console.log(`[Middleware] Request path: ${path}`);
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
// Define paths that don't require authentication
const publicPaths = ['/login', '/register', '/auth/callback'];
// Create a Supabase client configured to use cookies
const supabase = createMiddlewareClient({ req, res });
// API routes don't require authentication
if (path.startsWith('/api/')) {
console.log('[Middleware] API route, skipping validation');
return NextResponse.next();
}
// Static resources don't require authentication
if (path.includes('/_next/') || path.includes('/static/') || path.match(/\.(ico|png|jpg|jpeg|svg|css|js)$/)) {
console.log('[Middleware] Static resource, skipping validation');
return NextResponse.next();
}
// Refresh session if expired - required for Server Components
await supabase.auth.getSession();
// Check if it's a public path
const isPublicPath = publicPaths.some(publicPath => path === publicPath || path.startsWith(publicPath));
console.log(`[Middleware] Is public path: ${isPublicPath}`);
// Get all cookies
const allCookies = Object.fromEntries(request.cookies.getAll().map(c => [c.name, c.value]));
console.log('[Middleware] All cookies:', JSON.stringify(allCookies));
// Check each authentication cookie
const accessToken = request.cookies.get('sb-access-token');
const refreshToken = request.cookies.get('sb-refresh-token');
const providerToken = request.cookies.get('sb-provider-token');
const authToken = request.cookies.get('supabase-auth-token');
const customAuthToken = request.cookies.get('sb-auth-token');
console.log('[Middleware] Auth cookie details:', {
'sb-access-token': accessToken ? 'exists' : 'not found',
'sb-refresh-token': refreshToken ? 'exists' : 'not found',
'sb-provider-token': providerToken ? 'exists' : 'not found',
'supabase-auth-token': authToken ? 'exists' : 'not found',
'sb-auth-token': customAuthToken ? 'exists' : 'not found'
});
// Check if user is logged in
const isLoggedIn = !!(accessToken || refreshToken || providerToken || authToken || customAuthToken);
console.log(`[Middleware] User is logged in: ${isLoggedIn}`);
// If it's a public path but user is logged in, redirect to home page
if (isPublicPath && isLoggedIn) {
console.log('[Middleware] User is logged in and accessing public path, redirecting to home page');
return NextResponse.redirect(new URL('/', request.url));
}
// If it's not a public path and user is not logged in, redirect to login page
if (!isPublicPath && !isLoggedIn) {
console.log('[Middleware] User is not logged in and accessing private path, redirecting to login page');
const redirectUrl = new URL('/login', request.url);
redirectUrl.searchParams.set('redirect', encodeURIComponent(request.url));
return NextResponse.redirect(redirectUrl);
}
console.log('[Middleware] Validation passed, allowing access');
return NextResponse.next();
return res;
}
// Configure middleware matching paths
// Specify the paths where this middleware should run
export const config = {
matcher: [
// Match all paths, but exclude static resources
'/((?!_next/static|_next/image|favicon.ico).*)',
// Explicitly include important routes
'/',
'/analytics',
'/links',
'/create-shorturl',
],
};

View File

@@ -6,13 +6,8 @@ const nextConfig: NextConfig = {
// 配置实验性选项
experimental: {
// 启用边缘函数中间件
instrumentationHook: true,
// 配置中间件匹配
middleware: {
// 确保匹配所有路径
matchAll: '/((?!_next|static|api|public).*)',
},
// 禁用外部目录处理避免monorepo问题
// externalDir: true,
},
// 禁用严格模式,避免开发时重复渲染

28
pnpm-lock.yaml generated
View File

@@ -237,79 +237,67 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -378,28 +366,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.2.3':
resolution: {integrity: sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.2.3':
resolution: {integrity: sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.2.3':
resolution: {integrity: sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.2.3':
resolution: {integrity: sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==}
@@ -1180,28 +1164,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.0.15':
resolution: {integrity: sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.0.15':
resolution: {integrity: sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.0.15':
resolution: {integrity: sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-win32-arm64-msvc@4.0.15':
resolution: {integrity: sha512-7QtSSJwYZ7ZK1phVgcNZpuf7c7gaCj8Wb0xjliligT5qCGCp79OV2n3SJummVZdw4fbTNKUOYMO7m1GinppZyA==}
@@ -1370,25 +1350,21 @@ packages:
resolution: {integrity: sha512-fp4Azi8kHz6TX8SFmKfyScZrMLfp++uRm2srpqRjsRZIIBzH74NtSkdEUHImR4G7f7XJ+sVZjCc6KDDK04YEpQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/rspack-resolver-binding-linux-arm64-musl@1.2.2':
resolution: {integrity: sha512-gMiG3DCFioJxdGBzhlL86KcFgt9HGz0iDhw0YVYPsShItpN5pqIkNrI+L/Q/0gfDiGrfcE0X3VANSYIPmqEAlQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/rspack-resolver-binding-linux-x64-gnu@1.2.2':
resolution: {integrity: sha512-n/4n2CxaUF9tcaJxEaZm+lqvaw2gflfWQ1R9I7WQgYkKEKbRKbpG/R3hopYdUmLSRI4xaW1Cy0Bz40eS2Yi4Sw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/rspack-resolver-binding-linux-x64-musl@1.2.2':
resolution: {integrity: sha512-cHyhAr6rlYYbon1L2Ag449YCj3p6XMfcYTP0AQX+KkQo025d1y/VFtPWvjMhuEsE2lLvtHm7GdJozj6BOMtzVg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/rspack-resolver-binding-wasm32-wasi@1.2.2':
resolution: {integrity: sha512-eogDKuICghDLGc32FtP+WniG38IB1RcGOGz0G3z8406dUdjJvxfHGuGs/dSlM9YEp/v0lEqhJ4mBu6X2nL9pog==}
@@ -2325,28 +2301,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.29.2:
resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.29.2:
resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.29.2:
resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.29.2:
resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}

2
windmill/scripts/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/node_modules
/package-lock.json

View File

@@ -0,0 +1,19 @@
{
"name": "scripts",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"date-fns": "^4.1.0",
"dotenv": "^16.5.0",
"fs-extra": "^11.3.0",
"mongodb": "^6.16.0",
"node-fetch": "^2.7.0"
}
}

View File

@@ -0,0 +1,714 @@
// 从MongoDB的trace表同步数据到ClickHouse的events表
//
// 支持以下同步模式:
// 1. 增量同步:基于上次同步状态,只同步新数据(默认模式)
// 2. 自定义时间范围同步:通过指定开始时间和结束时间,同步特定时间范围内的数据
// - 可以通过时间戳参数(startTime/endTime)指定范围
// - 也可以通过日期字符串参数(startDate/endDate)指定范围支持ISO格式或yyyy-MM-dd格式
const { MongoClient, ObjectId } = require('mongodb');
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
// 同步状态键名和保存路径
const SYNC_STATE_FILE = path.join(__dirname, 'mongo_sync_state.json');
// 直接使用配置值
const mongoConfig = {
url: "mongodb://10.0.1.41:27017",
db: "main" // 注意:请替换为您的实际数据库名称
};
const clickhouseConfig = {
clickhouse_host: "10.0.1.60",
clickhouse_port: "8123",
clickhouse_user: "admin",
clickhouse_password: "your_secure_password",
clickhouse_database: "shorturl_analytics",
clickhouse_url: "http://10.0.1.60:8123"
};
// 封装本地读取变量函数
async function getVariable(key) {
try {
if (key === 'f/shorturl_analytics/mongodb') {
return mongoConfig;
} else if (key === 'f/shorturl_analytics/clickhouse') {
return clickhouseConfig;
} else if (key === 'f/shorturl_analytics/mongo_sync_state') {
if (fs.existsSync(SYNC_STATE_FILE)) {
return JSON.parse(fs.readFileSync(SYNC_STATE_FILE, 'utf8'));
}
}
return null;
} catch (error) {
console.error(`获取变量失败: ${error.message}`);
return null;
}
}
// 封装本地保存变量函数
async function setVariable(key, value) {
try {
if (key === 'f/shorturl_analytics/mongo_sync_state') {
fs.writeFileSync(SYNC_STATE_FILE, JSON.stringify(value, null, 2));
}
} catch (error) {
console.error(`保存变量失败: ${error.message}`);
throw error;
}
}
// 日期字符串转时间戳工具函数接受ISO字符串或yyyy-MM-dd格式
function dateToTimestamp(dateStr) {
try {
// 尝试直接解析完整的ISO日期字符串
const date = new Date(dateStr);
// 检查是否为有效日期
if (isNaN(date.getTime())) {
// 尝试解析yyyy-MM-dd格式默认设置为当天的00:00:00
const parts = dateStr.split('-');
if (parts.length === 3) {
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1; // 月份从0开始
const day = parseInt(parts[2], 10);
const dateObj = new Date(year, month, day, 0, 0, 0);
return dateObj.getTime();
}
throw new Error(`无法解析日期字符串: ${dateStr}`);
}
return date.getTime();
} catch (err) {
throw new Error(`日期转换错误: ${err instanceof Error ? err.message : String(err)}`);
}
}
// 从URL中提取UTM参数的函数增强版
function extractUtmParams(url, debug = false) {
const defaultUtmParams = {
utm_source: "",
utm_medium: "",
utm_campaign: "",
utm_term: "",
utm_content: ""
};
if (!url) return defaultUtmParams;
if (debug) {
console.log(`[UTM提取] 原始URL: ${url}`);
}
// 准备一个解析后的参数对象
const params = { ...defaultUtmParams };
// 尝试多种方法提取UTM参数
// 方法1: 使用URL对象解析
try {
// 先处理URL确保是完整的URL格式
let normalizedUrl = url;
if (!url.match(/^https?:\/\//i)) {
normalizedUrl = `https://example.com${url.startsWith('/') ? '' : '/'}${url}`;
}
const urlObj = new URL(normalizedUrl);
// 读取URL参数
if (urlObj.searchParams.has('utm_source'))
params.utm_source = urlObj.searchParams.get('utm_source') || "";
if (urlObj.searchParams.has('utm_medium'))
params.utm_medium = urlObj.searchParams.get('utm_medium') || "";
if (urlObj.searchParams.has('utm_campaign'))
params.utm_campaign = urlObj.searchParams.get('utm_campaign') || "";
if (urlObj.searchParams.has('utm_term'))
params.utm_term = urlObj.searchParams.get('utm_term') || "";
if (urlObj.searchParams.has('utm_content'))
params.utm_content = urlObj.searchParams.get('utm_content') || "";
if (debug) {
console.log(`[UTM提取] URL对象解析结果: ${JSON.stringify(params)}`);
}
// 如果至少找到一个UTM参数则返回
if (params.utm_source || params.utm_medium || params.utm_campaign ||
params.utm_term || params.utm_content) {
return params;
}
} catch (err) {
if (debug) {
console.log(`[UTM提取] URL对象解析失败尝试正则表达式`);
}
}
// 方法2: 使用正则表达式提取参数
// 使用正则表达式(最安全的方法,适用于任何格式)
const sourceMatch = url.match(/[?&]utm_source=([^&#]+)/i);
if (sourceMatch && sourceMatch[1]) {
try {
params.utm_source = decodeURIComponent(sourceMatch[1]);
} catch (err) {
params.utm_source = sourceMatch[1];
}
}
const mediumMatch = url.match(/[?&]utm_medium=([^&#]+)/i);
if (mediumMatch && mediumMatch[1]) {
try {
params.utm_medium = decodeURIComponent(mediumMatch[1]);
} catch (err) {
params.utm_medium = mediumMatch[1];
}
}
const campaignMatch = url.match(/[?&]utm_campaign=([^&#]+)/i);
if (campaignMatch && campaignMatch[1]) {
try {
params.utm_campaign = decodeURIComponent(campaignMatch[1]);
} catch (err) {
params.utm_campaign = campaignMatch[1];
}
}
const termMatch = url.match(/[?&]utm_term=([^&#]+)/i);
if (termMatch && termMatch[1]) {
try {
params.utm_term = decodeURIComponent(termMatch[1]);
} catch (err) {
params.utm_term = termMatch[1];
}
}
const contentMatch = url.match(/[?&]utm_content=([^&#]+)/i);
if (contentMatch && contentMatch[1]) {
try {
params.utm_content = decodeURIComponent(contentMatch[1]);
} catch (err) {
params.utm_content = contentMatch[1];
}
}
if (debug) {
console.log(`[UTM提取] 正则表达式解析结果: ${JSON.stringify(params)}`);
}
return params;
}
// 解析命令行参数
function parseCommandLineArgs() {
const args = {};
process.argv.slice(2).forEach(arg => {
if (arg.startsWith('--')) {
const [key, value] = arg.substring(2).split('=');
args[key] = value || true;
}
});
return args;
}
async function main() {
const args = parseCommandLineArgs();
// 参数设置
const batch_size = parseInt(args['batch-size'] || '1000');
const max_records = parseInt(args['max-records'] || '9999999');
const timeout_minutes = parseInt(args['timeout'] || '60');
const skip_clickhouse_check = args['skip-clickhouse-check'] === 'true';
const force_insert = args['force-insert'] !== 'false';
const database_override = args['database'] || 'shorturl_analytics';
const reset_sync_state = args['reset-sync-state'] === 'true';
const debug_utm = args['debug-utm'] === 'true';
const start_time = args['start-time'] ? parseInt(args['start-time']) : undefined;
const end_time = args['end-time'] ? parseInt(args['end-time']) : undefined;
const use_custom_time_range = args['use-custom-time-range'] === 'true';
const start_date = args['start-date'];
const end_date = args['end-date'];
const logWithTimestamp = (message) => {
const now = new Date();
console.log(`[${now.toISOString()}] ${message}`);
};
logWithTimestamp("开始执行MongoDB到ClickHouse的同步任务");
logWithTimestamp(`批处理大小: ${batch_size}, 最大记录数: ${max_records}, 超时时间: ${timeout_minutes}分钟`);
let customStartTime = start_time;
let customEndTime = end_time;
let useCustomTimeRange = use_custom_time_range;
// 处理日期字符串参数,转换为时间戳
if (start_date) {
try {
customStartTime = dateToTimestamp(start_date);
logWithTimestamp(`将开始日期 ${start_date} 转换为时间戳 ${customStartTime}`);
useCustomTimeRange = true;
} catch (err) {
logWithTimestamp(`开始日期转换错误: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (end_date) {
try {
customEndTime = dateToTimestamp(end_date);
// 如果是日期格式,设置为当天结束时间 (23:59:59.999)
if (end_date.split('-').length === 3 && end_date.length <= 10) {
customEndTime += 24 * 60 * 60 * 1000 - 1; // 加上23:59:59.999
logWithTimestamp(`将结束日期 ${end_date} 转换为当天结束时间戳 ${customEndTime}`);
} else {
logWithTimestamp(`将结束日期 ${end_date} 转换为时间戳 ${customEndTime}`);
}
useCustomTimeRange = true;
} catch (err) {
logWithTimestamp(`结束日期转换错误: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (skip_clickhouse_check) {
logWithTimestamp("⚠️ 警告: 已启用跳过ClickHouse检查模式不会检查记录是否已存在");
}
if (force_insert) {
logWithTimestamp("⚠️ 警告: 已启用强制插入模式,将尝试插入所有记录");
}
if (reset_sync_state) {
logWithTimestamp("⚠️ 警告: 已启用重置同步状态,将从头开始同步数据");
}
if (debug_utm) {
logWithTimestamp("已启用UTM参数调试日志");
}
if (useCustomTimeRange) {
if (customStartTime) {
logWithTimestamp(`已启用自定义时间范围:开始时间 ${new Date(customStartTime).toISOString()}`);
}
if (customEndTime) {
logWithTimestamp(`已启用自定义时间范围:结束时间 ${new Date(customEndTime).toISOString()}`);
}
}
// 设置超时
const startTime = Date.now();
const timeoutMs = timeout_minutes * 60 * 1000;
// 检查是否超时
const checkTimeout = () => {
if (Date.now() - startTime > timeoutMs) {
console.log(`运行时间超过${timeout_minutes}分钟,暂停执行`);
return true;
}
return false;
};
// 获取上次同步状态
let lastSyncState = null;
if (!reset_sync_state) {
try {
const rawSyncState = await getVariable("f/shorturl_analytics/mongo_sync_state");
if (rawSyncState) {
lastSyncState = rawSyncState;
}
} catch (error) {
logWithTimestamp(`获取上次同步状态失败: ${error}, 将从头开始同步`);
}
}
if (lastSyncState) {
logWithTimestamp(`找到上次同步状态: 最后同步时间 ${new Date(lastSyncState.last_sync_time).toISOString()}, 已同步记录数 ${lastSyncState.records_synced}`);
if (lastSyncState.last_sync_id) {
logWithTimestamp(`最后同步ID: ${lastSyncState.last_sync_id}`);
}
} else {
logWithTimestamp("没有找到上次同步状态,将从头开始同步");
}
// 连接MongoDB
const client = new MongoClient(mongoConfig.url);
try {
await client.connect();
console.log("MongoDB连接成功");
const db = client.db(mongoConfig.db);
const traceCollection = db.collection("trace");
const shortCollection = db.collection("short");
// 构建查询条件
const query = {
type: 1 // 只同步type为1的记录
};
// 根据时间范围参数构建查询条件
if (useCustomTimeRange) {
// 使用自定义时间范围
const timeQuery = {};
if (customStartTime) {
timeQuery.$gte = customStartTime;
logWithTimestamp(`将只同步createTime >= ${customStartTime} (${new Date(customStartTime).toISOString()}) 的记录`);
}
if (customEndTime) {
timeQuery.$lte = customEndTime;
logWithTimestamp(`将只同步createTime <= ${customEndTime} (${new Date(customEndTime).toISOString()}) 的记录`);
}
// 只有当至少指定了一个时间限制时才添加时间查询条件
if (Object.keys(timeQuery).length > 0) {
query.createTime = timeQuery;
}
}
// 如果不使用自定义时间范围,且有上次同步状态,则只获取更新的记录
else if (lastSyncState && lastSyncState.last_sync_time) {
// 使用上次同步时间作为过滤条件
query.createTime = { $gt: lastSyncState.last_sync_time };
logWithTimestamp(`将只同步createTime > ${lastSyncState.last_sync_time} (${new Date(lastSyncState.last_sync_time).toISOString()}) 的记录`);
}
// 计算总记录数
const totalRecords = await traceCollection.countDocuments(query);
console.log(`找到 ${totalRecords} 条新记录需要同步`);
// 限制此次处理的记录数量
const recordsToProcess = Math.min(totalRecords, max_records);
console.log(`本次将处理 ${recordsToProcess} 条记录`);
if (totalRecords === 0) {
console.log("没有新记录需要同步,任务完成");
return {
success: true,
records_synced: 0,
message: "没有新记录需要同步"
};
}
// 检查ClickHouse连接状态
const checkClickHouseConnection = async () => {
if (skip_clickhouse_check) {
logWithTimestamp("已启用跳过ClickHouse检查不测试连接");
return true;
}
try {
logWithTimestamp("测试ClickHouse连接...");
const clickhouseUrl = clickhouseConfig.clickhouse_url;
const response = await fetch(clickhouseUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Basic ${Buffer.from(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`).toString('base64')}`,
},
body: `SELECT 1 FROM ${clickhouseConfig.clickhouse_database}.events LIMIT 1`,
});
if (response.ok) {
logWithTimestamp("ClickHouse连接测试成功");
return true;
} else {
const errorText = await response.text();
logWithTimestamp(`ClickHouse连接测试失败: ${response.status} ${errorText}`);
return false;
}
} catch (err) {
logWithTimestamp(`ClickHouse连接测试失败: ${err.message}`);
return false;
}
};
// 在处理记录前先检查ClickHouse连接
const clickhouseConnected = await checkClickHouseConnection();
if (!clickhouseConnected && !skip_clickhouse_check) {
logWithTimestamp("⚠️ ClickHouse连接测试失败请启用skip_clickhouse_check=true参数来跳过连接检查");
throw new Error("ClickHouse连接失败无法继续同步");
}
// 处理记录的函数
const processRecords = async (records) => {
if (records.length === 0) return 0;
logWithTimestamp(`开始处理批次数据,共 ${records.length} 条记录...`);
// 强制使用所有记录,不检查重复
const newRecords = records;
logWithTimestamp(`准备处理 ${newRecords.length} 条记录...`);
// 获取链接信息
const slugIds = newRecords.map(record => new ObjectId(record.slugId));
logWithTimestamp(`正在查询 ${slugIds.length} 条短链接信息...`);
const shortLinks = await shortCollection.find({
_id: { $in: slugIds }
}).toArray();
// 创建映射用于快速查找
const shortLinksMap = new Map(shortLinks.map((link) => [link._id.toString(), link]));
logWithTimestamp(`获取到 ${shortLinks.length} 条短链接信息,${newRecords.length - shortLinks.length} 条数据将使用占位符`);
// 准备ClickHouse插入数据
const clickhouseData = newRecords.map(record => {
const eventTime = new Date(record.createTime);
// 获取对应的短链接信息
const shortLink = shortLinksMap.get(record.slugId.toString());
// 提取URL中的UTM参数
if (debug_utm && record.url) {
logWithTimestamp(`======== UTM参数调试 ========`);
logWithTimestamp(`记录ID: ${record._id.toString()}`);
logWithTimestamp(`原始URL: ${record.url}`);
}
const utmParams = extractUtmParams(record.url || "", debug_utm);
if (debug_utm) {
logWithTimestamp(`提取的UTM参数: ${JSON.stringify(utmParams)}`);
logWithTimestamp(`===========================`);
}
// 保存提取的UTM参数和URL到event_attributes
const eventAttributes = {
mongo_id: record._id.toString(),
url: record.url || "",
...(record.url ? { raw_url: record.url } : {})
};
// 转换MongoDB记录为ClickHouse格式匹配ClickHouse表结构
return {
// UUID将由ClickHouse自动生成 (event_id)
event_time: eventTime.toISOString().replace('T', ' ').replace('Z', ''),
event_type: record.type === 1 ? "visit" : "custom",
event_attributes: JSON.stringify(eventAttributes),
link_id: record.slugId.toString(),
link_slug: shortLink?.slug || "unknown_slug", // 使用占位符
link_label: record.label || "",
link_title: shortLink?.title || "unknown_title", // 使用占位符
link_original_url: shortLink?.origin || "https://unknown.url", // 使用占位符
link_attributes: JSON.stringify({ domain: shortLink?.domain || "unknown_domain" }), // 使用占位符
link_created_at: shortLink?.createTime
? new Date(shortLink.createTime).toISOString().replace('T', ' ').replace('Z', '')
: eventTime.toISOString().replace('T', ' ').replace('Z', ''),
link_expires_at: shortLink?.expiresAt
? new Date(shortLink.expiresAt).toISOString().replace('T', ' ').replace('Z', '')
: null,
link_tags: shortLink?.tags ? JSON.stringify(shortLink.tags) : "[]",
user_id: shortLink?.user || "unknown_user", // 使用占位符
user_name: "unknown_user", // 使用占位符
user_email: "",
user_attributes: "{}",
team_id: shortLink?.teamId || "unknown_team", // 使用占位符
team_name: "unknown_team", // 使用占位符
team_attributes: "{}",
project_id: shortLink?.projectId || "unknown_project", // 使用占位符
project_name: "unknown_project", // 使用占位符
project_attributes: "{}",
qr_code_id: "",
qr_code_name: "",
qr_code_attributes: "{}",
visitor_id: record._id.toString(),
session_id: record._id.toString() + "-" + record.createTime,
ip_address: record.ip || "0.0.0.0", // 使用占位符
country: "",
city: "",
device_type: record.platform || "unknown",
browser: record.browser || "unknown", // 使用占位符
os: record.platformOS || "unknown", // 使用占位符
user_agent: (record.browser || "unknown") + " " + (record.browserVersion || "unknown"), // 使用占位符
referrer: record.url || "",
utm_source: utmParams.utm_source || "",
utm_medium: utmParams.utm_medium || "",
utm_campaign: utmParams.utm_campaign || "",
utm_term: utmParams.utm_term || "",
utm_content: utmParams.utm_content || "",
time_spent_sec: 0,
is_bounce: true,
is_qr_scan: false,
conversion_type: "visit",
conversion_value: 0,
req_full_path: record.url || ""
};
});
// 生成ClickHouse插入SQL
const insertSQL = `
INSERT INTO ${clickhouseConfig.clickhouse_database}.events
(event_time, event_type, event_attributes, link_id, link_slug, link_label, link_title,
link_original_url, link_attributes, link_created_at, link_expires_at, link_tags,
user_id, user_name, user_email, user_attributes, team_id, team_name, team_attributes,
project_id, project_name, project_attributes, qr_code_id, qr_code_name, qr_code_attributes,
visitor_id, session_id, ip_address, country, city, device_type, browser, os, user_agent,
referrer, utm_source, utm_medium, utm_campaign, utm_term, utm_content, time_spent_sec,
is_bounce, is_qr_scan, conversion_type, conversion_value, req_full_path)
VALUES ${clickhouseData.map(record => {
// 确保所有字符串值都是字符串类型,并安全处理替换
const safeReplace = (val) => {
// 确保值是字符串如果是null或undefined则使用空字符串
const str = val === null || val === undefined ? "" : String(val);
// 安全替换单引号
return str.replace(/'/g, "''");
};
return `('${record.event_time}', '${safeReplace(record.event_type)}', '${safeReplace(record.event_attributes)}',
'${record.link_id}', '${safeReplace(record.link_slug)}', '${safeReplace(record.link_label)}', '${safeReplace(record.link_title)}',
'${safeReplace(record.link_original_url)}', '${safeReplace(record.link_attributes)}', '${record.link_created_at}',
${record.link_expires_at === null ? 'NULL' : `'${record.link_expires_at}'`}, '${safeReplace(record.link_tags)}',
'${safeReplace(record.user_id)}', '${safeReplace(record.user_name)}', '${safeReplace(record.user_email)}',
'${safeReplace(record.user_attributes)}', '${safeReplace(record.team_id)}', '${safeReplace(record.team_name)}',
'${safeReplace(record.team_attributes)}', '${safeReplace(record.project_id)}', '${safeReplace(record.project_name)}',
'${safeReplace(record.project_attributes)}', '${safeReplace(record.qr_code_id)}', '${safeReplace(record.qr_code_name)}',
'${safeReplace(record.qr_code_attributes)}', '${safeReplace(record.visitor_id)}', '${safeReplace(record.session_id)}',
'${safeReplace(record.ip_address)}', '${safeReplace(record.country)}', '${safeReplace(record.city)}',
'${safeReplace(record.device_type)}', '${safeReplace(record.browser)}', '${safeReplace(record.os)}',
'${safeReplace(record.user_agent)}', '${safeReplace(record.referrer)}', '${safeReplace(record.utm_source)}',
'${safeReplace(record.utm_medium)}', '${safeReplace(record.utm_campaign)}', '${safeReplace(record.utm_term)}',
'${safeReplace(record.utm_content)}', ${record.time_spent_sec}, ${record.is_bounce}, ${record.is_qr_scan},
'${safeReplace(record.conversion_type)}', ${record.conversion_value}, '${safeReplace(record.req_full_path)}')`;
}).join(", ")}
`;
if (insertSQL.length === 0) {
console.log("没有新记录需要插入");
return 0;
}
// 发送请求到ClickHouse
const clickhouseUrl = clickhouseConfig.clickhouse_url;
try {
logWithTimestamp("发送插入请求到ClickHouse...");
const response = await fetch(clickhouseUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Basic ${Buffer.from(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`).toString('base64')}`
},
body: insertSQL,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`ClickHouse插入错误: ${response.status} ${errorText}`);
}
logWithTimestamp(`成功插入 ${newRecords.length} 条记录到ClickHouse`);
return newRecords.length;
} catch (err) {
logWithTimestamp(`向ClickHouse插入数据失败: ${err.message}`);
throw err;
}
};
// 批量处理记录
let processedRecords = 0;
let totalBatchRecords = 0;
let lastSyncTime = 0;
for (let page = 0; processedRecords < recordsToProcess; page++) {
// 检查超时
if (checkTimeout()) {
logWithTimestamp(`已处理 ${processedRecords}/${recordsToProcess} 条记录,因超时暂停执行`);
break;
}
// 每批次都输出进度
logWithTimestamp(`开始处理第 ${page+1} 批次,已完成 ${processedRecords}/${recordsToProcess} 条记录 (${Math.round(processedRecords/recordsToProcess*100)}%)`);
logWithTimestamp(`正在从MongoDB获取第 ${page+1} 批次数据...`);
// 查询MongoDB数据
const records = await traceCollection.find(query)
.sort({ createTime: 1 })
.skip(page * batch_size)
.limit(batch_size)
.toArray();
if (records.length === 0) {
logWithTimestamp("没有找到更多数据,同步结束");
break;
}
// 找到数据,开始处理
logWithTimestamp(`获取到 ${records.length} 条记录,开始处理...`);
// 输出当前批次的部分数据信息
if (records.length > 0) {
logWithTimestamp(`批次 ${page+1} 第一条记录: ID=${records[0]._id}, 时间=${new Date(records[0].createTime).toISOString()}`);
if (records.length > 1) {
logWithTimestamp(`批次 ${page+1} 最后一条记录: ID=${records[records.length-1]._id}, 时间=${new Date(records[records.length-1].createTime).toISOString()}`);
}
// 如果开启了调试输出一些URL样本
if (debug_utm) {
const sampleSize = Math.min(5, records.length);
logWithTimestamp(`URL样本 (前${sampleSize}条):`);
for (let i = 0; i < sampleSize; i++) {
if (records[i].url) {
logWithTimestamp(`样本 ${i+1}: ${records[i].url}`);
}
}
}
}
const batchSize = await processRecords(records);
processedRecords += records.length;
totalBatchRecords += batchSize;
// 更新最后处理的记录时间和ID
if (records.length > 0) {
const lastRecord = records[records.length - 1];
lastSyncTime = Math.max(lastSyncTime, lastRecord.createTime);
}
logWithTimestamp(`${page+1} 批次处理完成。已处理 ${processedRecords}/${recordsToProcess} 条记录,实际插入 ${totalBatchRecords} 条 (${Math.round(processedRecords/recordsToProcess*100)}%)`);
}
// 更新同步状态
if (processedRecords > 0 && lastSyncTime > 0) {
// 只在非自定义时间范围模式下更新同步状态
if (!useCustomTimeRange) {
// 创建新的同步状态,简化对象结构
const newSyncState = {
last_sync_time: lastSyncTime,
records_synced: (lastSyncState ? lastSyncState.records_synced : 0) + processedRecords
};
try {
// 保存同步状态
await setVariable("f/shorturl_analytics/mongo_sync_state", newSyncState);
logWithTimestamp(`同步状态已更新: 最后同步时间 ${new Date(newSyncState.last_sync_time).toISOString()}, 累计同步记录数 ${newSyncState.records_synced}`);
} catch (err) {
logWithTimestamp(`更新同步状态失败: ${err.message},将继续执行`);
}
} else {
logWithTimestamp("使用自定义时间范围模式,不更新全局同步状态");
}
}
return {
success: true,
records_processed: processedRecords,
records_synced: totalBatchRecords,
last_sync_time: lastSyncTime > 0 ? new Date(lastSyncTime).toISOString() : null,
message: useCustomTimeRange ? "自定义时间范围数据同步完成" : "数据同步完成",
custom_time_range_used: useCustomTimeRange
};
} catch (err) {
console.error("同步过程中发生错误:", err);
return {
success: false,
error: err.message,
stack: err.stack
};
} finally {
// 关闭MongoDB连接
await client.close();
console.log("MongoDB连接已关闭");
}
}
// 执行主函数
main().then(result => {
console.log("任务执行结果:", result);
process.exit(result.success ? 0 : 1);
}).catch(err => {
console.error("执行出错:", err);
process.exit(1);
});

View File

@@ -1,4 +1,12 @@
// 从MongoDB的trace表同步数据到ClickHouse的events表
//
// 支持以下同步模式:
// 1. 增量同步:基于上次同步状态,只同步新数据(默认模式)
// 2. 自定义时间范围同步:通过指定开始时间和结束时间,同步特定时间范围内的数据
// - 可以通过时间戳参数(start_time/end_time)指定范围
// - 也可以通过日期字符串参数(start_date/end_date)指定范围支持ISO格式或yyyy-MM-dd格式
//
// 使用自定义时间范围时,将不会更新同步状态,避免干扰增量同步进度
import { getVariable, setVariable } from "npm:windmill-client@1";
import { MongoClient, ObjectId } from "https://deno.land/x/mongo@v0.32.0/mod.ts";
@@ -68,6 +76,33 @@ interface UtmParams {
// 同步状态键名
const SYNC_STATE_KEY = "f/shorturl_analytics/mongo_sync_state";
// 日期字符串转时间戳工具函数接受ISO字符串或yyyy-MM-dd格式
function dateToTimestamp(dateStr: string): number {
try {
// 尝试直接解析完整的ISO日期字符串
const date = new Date(dateStr);
// 检查是否为有效日期
if (isNaN(date.getTime())) {
// 尝试解析yyyy-MM-dd格式默认设置为当天的00:00:00
const parts = dateStr.split('-');
if (parts.length === 3) {
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1; // 月份从0开始
const day = parseInt(parts[2], 10);
const dateObj = new Date(year, month, day, 0, 0, 0);
return dateObj.getTime();
}
throw new Error(`无法解析日期字符串: ${dateStr}`);
}
return date.getTime();
} catch (err) {
throw new Error(`日期转换错误: ${err instanceof Error ? err.message : String(err)}`);
}
}
// 从URL中提取UTM参数的函数增强版
function extractUtmParams(url: string, debug = false): UtmParams {
const defaultUtmParams: UtmParams = {
@@ -188,7 +223,12 @@ export async function main(
force_insert = true,
database_override = "shorturl_analytics", // 添加数据库名称参数默认为shorturl_analytics
reset_sync_state = false, // 添加参数用于重置同步状态
debug_utm = false // 添加参数控制UTM调试日志输出
debug_utm = false, // 添加参数控制UTM调试日志输出
start_time?: number, // 添加参数指定同步的开始时间戳,可选
end_time?: number, // 添加参数指定同步的结束时间戳,可选
use_custom_time_range = false, // 添加参数控制是否使用自定义时间范围
start_date?: string, // 添加开始日期字符串参数ISO格式或yyyy-MM-dd格式
end_date?: string // 添加结束日期字符串参数ISO格式或yyyy-MM-dd格式
) {
const logWithTimestamp = (message: string) => {
const now = new Date();
@@ -197,6 +237,34 @@ export async function main(
logWithTimestamp("开始执行MongoDB到ClickHouse的同步任务");
logWithTimestamp(`批处理大小: ${batch_size}, 最大记录数: ${max_records}, 超时时间: ${timeout_minutes}分钟`);
// 处理日期字符串参数,转换为时间戳
if (start_date) {
try {
start_time = dateToTimestamp(start_date);
logWithTimestamp(`将开始日期 ${start_date} 转换为时间戳 ${start_time}`);
use_custom_time_range = true;
} catch (err) {
logWithTimestamp(`开始日期转换错误: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (end_date) {
try {
end_time = dateToTimestamp(end_date);
// 如果是日期格式,设置为当天结束时间 (23:59:59.999)
if (end_date.split('-').length === 3 && end_date.length <= 10) {
end_time += 24 * 60 * 60 * 1000 - 1; // 加上23:59:59.999
logWithTimestamp(`将结束日期 ${end_date} 转换为当天结束时间戳 ${end_time}`);
} else {
logWithTimestamp(`将结束日期 ${end_date} 转换为时间戳 ${end_time}`);
}
use_custom_time_range = true;
} catch (err) {
logWithTimestamp(`结束日期转换错误: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (skip_clickhouse_check) {
logWithTimestamp("⚠️ 警告: 已启用跳过ClickHouse检查模式不会检查记录是否已存在");
}
@@ -209,6 +277,14 @@ export async function main(
if (debug_utm) {
logWithTimestamp("已启用UTM参数调试日志");
}
if (use_custom_time_range) {
if (start_time) {
logWithTimestamp(`已启用自定义时间范围:开始时间 ${new Date(start_time).toISOString()}`);
}
if (end_time) {
logWithTimestamp(`已启用自定义时间范围:结束时间 ${new Date(end_time).toISOString()}`);
}
}
// 设置超时
const startTime = Date.now();
@@ -328,11 +404,31 @@ export async function main(
// 构建查询条件,根据上次同步状态获取新记录
const query: Record<string, unknown> = {
type: 1 // 只同步type为1的记录
// 删除了 type: 1 的条件,将同步所有数据
};
// 如果有上次同步状态,则只获取更新的记录
if (lastSyncState && lastSyncState.last_sync_time) {
// 根据时间范围参数构建查询条件
if (use_custom_time_range) {
// 使用自定义时间范围
const timeQuery: Record<string, number> = {};
if (start_time) {
timeQuery.$gte = start_time;
logWithTimestamp(`将只同步createTime >= ${start_time} (${new Date(start_time).toISOString()}) 的记录`);
}
if (end_time) {
timeQuery.$lte = end_time;
logWithTimestamp(`将只同步createTime <= ${end_time} (${new Date(end_time).toISOString()}) 的记录`);
}
// 只有当至少指定了一个时间限制时才添加时间查询条件
if (Object.keys(timeQuery).length > 0) {
query.createTime = timeQuery;
}
}
// 如果不使用自定义时间范围,且有上次同步状态,则只获取更新的记录
else if (lastSyncState && lastSyncState.last_sync_time) {
// 使用上次同步时间作为过滤条件
query.createTime = { $gt: lastSyncState.last_sync_time };
logWithTimestamp(`将只同步createTime > ${lastSyncState.last_sync_time} (${new Date(lastSyncState.last_sync_time).toISOString()}) 的记录`);
@@ -452,7 +548,7 @@ export async function main(
return {
// UUID将由ClickHouse自动生成 (event_id)
event_time: eventTime.toISOString().replace('T', ' ').replace('Z', ''),
event_type: record.type === 1 ? "visit" : "custom",
event_type: "click", // 将所有event_type都设置为click
event_attributes: JSON.stringify(eventAttributes),
link_id: record.slugId.toString(),
link_slug: shortLink?.slug || "unknown_slug", // 使用占位符
@@ -467,15 +563,15 @@ export async function main(
? new Date(shortLink.expiresAt).toISOString().replace('T', ' ').replace('Z', '')
: null,
link_tags: shortLink?.tags ? JSON.stringify(shortLink.tags) : "[]",
user_id: shortLink?.user || "unknown_user", // 使用占位符
user_id: "3680f452-e404-4339-a3d2-2a8e1ff92102", // 使用占位符
user_name: "unknown_user", // 使用占位符
user_email: "",
user_attributes: "{}",
team_id: shortLink?.teamId || "unknown_team", // 使用占位符
team_name: "unknown_team", // 使用占位符
team_id: "e02251eb-eb98-47c8-b5dd-4f6e4fdb1f49", // 使用占位符
team_name: "", // 使用占位符
team_attributes: "{}",
project_id: shortLink?.projectId || "unknown_project", // 使用占位符
project_name: "unknown_project", // 使用占位符
project_id: "34cdb8b9-8b8e-4033-876a-0632002ef1f9", // 使用占位符
project_name: "", // 使用占位符
project_attributes: "{}",
qr_code_id: "",
qr_code_name: "",
@@ -515,12 +611,23 @@ export async function main(
referrer, utm_source, utm_medium, utm_campaign, utm_term, utm_content, time_spent_sec,
is_bounce, is_qr_scan, conversion_type, conversion_value, req_full_path)
VALUES ${clickhouseData.map(record => {
// 确保所有字符串值都是字符串类型,并安全处理替换
// 增强版安全替换函数,处理所有特殊字符
const safeReplace = (val: unknown): string => {
// 确保值是字符串如果是null或undefined则使用空字符串
const str = val === null || val === undefined ? "" : String(val);
// 安全替换单引号
return str.replace(/'/g, "''");
// 转义所有可能导致SQL注入或格式错误的字符
// 1. 先替换所有反斜杠
// 2. 再替换单引号
// 3. 替换所有控制字符和特殊字符
return str
.replace(/\\/g, "\\\\") // 转义反斜杠
.replace(/'/g, "\\'") // 转义单引号
.replace(/\r/g, "\\r") // 转义回车
.replace(/\n/g, "\\n") // 转义换行
.replace(/\t/g, "\\t") // 转义制表符
.replace(/\0/g, "") // 移除空字符
.replace(/[\x00-\x1F\x7F-\x9F]/g, ""); // 移除所有控制字符
};
return `('${record.event_time}', '${safeReplace(record.event_type)}', '${safeReplace(record.event_attributes)}',
@@ -641,20 +748,25 @@ export async function main(
// 更新同步状态
if (processedRecords > 0 && lastSyncTime > 0) {
// 创建新的同步状态,简化对象结构
const newSyncState: SyncState = {
last_sync_time: lastSyncTime,
records_synced: (lastSyncState ? lastSyncState.records_synced : 0) + processedRecords, // 使用处理的总记录数,而不是实际插入数
};
try {
// 保存同步状态
await setVariable(SYNC_STATE_KEY, newSyncState);
logWithTimestamp(`同步状态已更新: 最后同步时间 ${new Date(newSyncState.last_sync_time).toISOString()}, 累计同步记录数 ${newSyncState.records_synced}`);
} catch (err) {
const error = err as Error;
logWithTimestamp(`更新同步状态失败: ${error.message},将继续执行`);
// 不抛出错误,继续执行
// 只在非自定义时间范围模式下更新同步状态
if (!use_custom_time_range) {
// 创建新的同步状态,简化对象结构
const newSyncState: SyncState = {
last_sync_time: lastSyncTime,
records_synced: (lastSyncState ? lastSyncState.records_synced : 0) + processedRecords, // 使用处理的总记录数,而不是实际插入数
};
try {
// 保存同步状态
await setVariable(SYNC_STATE_KEY, newSyncState);
logWithTimestamp(`同步状态已更新: 最后同步时间 ${new Date(newSyncState.last_sync_time).toISOString()}, 累计同步记录数 ${newSyncState.records_synced}`);
} catch (err) {
const error = err as Error;
logWithTimestamp(`更新同步状态失败: ${error.message},将继续执行`);
// 不抛出错误,继续执行
}
} else {
logWithTimestamp("使用自定义时间范围模式,不更新全局同步状态");
}
}
@@ -663,7 +775,8 @@ export async function main(
records_processed: processedRecords,
records_synced: totalBatchRecords,
last_sync_time: lastSyncTime > 0 ? new Date(lastSyncTime).toISOString() : null,
message: "数据同步完成"
message: use_custom_time_range ? "自定义时间范围数据同步完成" : "数据同步完成",
custom_time_range_used: use_custom_time_range
};
} catch (err) {
console.error("同步过程中发生错误:", err);

View File

@@ -125,7 +125,7 @@ export async function main(
user: pgConfig.user,
password: pgConfig.password,
database: pgConfig.dbname || 'postgres'
}, 1);
}, 3);
console.log("PostgreSQL连接池创建完成尝试连接...");