5 Commits

31 changed files with 1209 additions and 1464 deletions

4
.env
View File

@@ -1,7 +1,5 @@
PORT=3007 PORT=3007
MONGO_URL="mongodb://10.0.1.41:27017"
# ClickHouse Configuration # ClickHouse Configuration
CLICKHOUSE_HOST=10.0.1.60 CLICKHOUSE_HOST=10.0.1.60
CLICKHOUSE_PORT=8123 CLICKHOUSE_PORT=8123
@@ -27,5 +25,3 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJz
DATABASE_URL="postgresql://postgres.mwwvqwevplndzvmqmrxa:eYYdarJsRL*Z6&p9gD@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres" 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"

View File

@@ -1,42 +0,0 @@
# 身份验证重定向 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`
通过正确设置此变量,您可以确保无论在何处运行应用,邮件中的链接都能正确指向应用的实际位置。

View File

@@ -1,50 +0,0 @@
# 配置 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,65 +1,71 @@
'use client';
import ProtectedRoute from '@/app/components/ProtectedRoute';
export default function HomePage() { export default function HomePage() {
return ( return (
<div className="container mx-auto px-4 py-8"> <ProtectedRoute>
<div className="text-center"> <div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold text-gray-900 mb-8"> <div className="text-center">
Welcome to ShortURL Analytics <h1 className="text-4xl font-bold text-gray-900 mb-8">
</h1> 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> </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 { TagSelector } from '@/app/components/ui/TagSelector';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useShortUrlStore } from '@/app/utils/store'; import { useShortUrlStore } from '@/app/utils/store';
import ClientRouteGuard from '@/app/components/ClientRouteGuard'; import ProtectedRoute from '@/app/components/ProtectedRoute';
// 事件类型定义 // 事件类型定义
interface Event { interface Event {
@@ -1110,7 +1110,7 @@ function AnalyticsContent() {
// Main page component with Suspense // Main page component with Suspense
export default function AnalyticsPage() { export default function AnalyticsPage() {
return ( return (
<ClientRouteGuard> <ProtectedRoute>
<Suspense fallback={ <Suspense fallback={
<div className="flex items-center justify-center min-h-screen"> <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" /> <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 /> <AnalyticsContent />
</Suspense> </Suspense>
</ClientRouteGuard> </ProtectedRoute>
); );
} }

View File

@@ -1,126 +1,126 @@
# Activities API Documentation # 数据分析活动接口说明
## 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. `/api/activities` 端点提供了访问和导出分析事件数据的功能,可用于查询短链接的点击和访问记录。
## Endpoint ## 请求方式
``` - HTTP 方法: **GET**
GET /api/activities - URL: `/api/activities`
```
## Request Parameters ## 请求参数
| Parameter | Type | Required | Description | | 参数名 | 类型 | 必填 | 说明 |
|------------|---------|----------|-------------| |-------|------|------|------|
| `slug` | string | No* | The short URL slug to filter events by | | slug | string | 否* | 短链接标识符 |
| `domain` | string | No* | The domain to filter events by | | domain | string | 否* | 短链接域名 |
| `startTime`| string | No* | Start time for date range filter (ISO format) | | format | string | 否 | 响应格式,可选值: `csv`纯文本显示不传则默认返回JSON |
| `endTime` | string | No* | End time for date range filter (ISO format) | | startTime | string | 否* | 起始时间ISO格式 |
| `page` | integer | No | Page number for pagination (default: 1) | | endTime | string | 否* | 结束时间ISO格式 |
| `pageSize` | integer | No | Number of records per page (default: 50) | | page | number | 否 | 当前页码默认为1 |
| `format` | string | No | Response format, set to 'csv' for CSV output (default: JSON) | | pageSize | number | 否 | 每页记录数默认为50 |
\* Either `slug`+`domain` combination OR at least one of `startTime`/`endTime` must be provided. _*注:必须提供 (slug和domain) 或 (startTimeendTime) 中的至少一组过滤条件_
## Response Formats ## 响应格式
### JSON Format (Default) ### JSON格式(默认)
JSON responses include the following structure:
```json ```json
{ {
"success": true, "success": true,
"data": [ "data": [
{ {
"id": "event-id", "id": "事件ID",
"type": "event-type", "type": "事件类型",
"time": "timestamp", "time": "事件时间",
"visitor": { "visitor": {
"id": "visitor-id", "id": "访问者ID",
"ipAddress": "ip-address", "ipAddress": "IP地址",
"userAgent": "user-agent-string", "userAgent": "浏览器用户代理",
"referrer": "referrer-url" "referrer": "来源页面"
}, },
"device": { "device": {
"type": "device-type", "type": "设备类型",
"browser": "browser-name", "browser": "浏览器",
"os": "operating-system" "os": "操作系统"
}, },
"location": { "location": {
"country": "country-code", "country": "国家",
"city": "city-name" "city": "城市"
}, },
"link": { "link": {
"id": "link-id", "id": "链接ID",
"slug": "link-slug", "slug": "短链标识",
"originalUrl": "original-url", "originalUrl": "原始链接",
"label": "link-label", "label": "链接标签",
"tags": ["tag1", "tag2"] "tags": ["标签1", "标签2"]
}, },
"utm": { "utm": {
"source": "utm-source", "source": "来源",
"medium": "utm-medium", "medium": "媒介",
"campaign": "utm-campaign", "campaign": "活动",
"term": "utm-term", "term": "关键词",
"content": "utm-content" "content": "内容"
} }
} }
], ],
"meta": { "meta": {
"total": 100, "total": ,
"page": 1, "page": ,
"pageSize": 50 "pageSize":
} }
} }
``` ```
In case of an error: ### 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
```
## 错误响应
当请求参数不正确或服务器发生错误时,返回以下格式:
```json ```json
{ {
"success": false, "success": false,
"data": null, "error": "错误描述信息"
"error": "Error message description"
} }
``` ```
### CSV Format 常见错误代码:
When `format=csv` is specified, the response is returned as plain text in CSV format with the following columns: - 400: 参数错误,例如缺少必要的过滤条件
- `time`: Timestamp of the event - 500: 服务器内部错误
- `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 { getEvents } from '@/lib/analytics';
import { ApiResponse } from '@/lib/types'; import { ApiResponse } from '@/lib/types';
// Extended Event type with required fields // 扩展Event类型以包含所需字段
interface EventWithFullPath { interface EventWithFullPath extends Record<string, any> {
event_id?: string; event_id?: string;
event_time?: string; event_time?: string;
event_type?: string; event_type?: string;
@@ -11,18 +11,7 @@ interface EventWithFullPath {
ip_address?: string; ip_address?: string;
req_full_path?: string; req_full_path?: string;
referrer?: 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) { export async function GET(request: NextRequest) {
@@ -38,19 +27,12 @@ export async function GET(request: NextRequest) {
const startTime = searchParams.get('startTime') || undefined; const startTime = searchParams.get('startTime') || undefined;
const endTime = searchParams.get('endTime') || 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)) { if ((!slug && !domain) && (!startTime && !endTime)) {
return NextResponse.json({ return NextResponse.json({
success: false, 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 }); }, { status: 400 });
} }
@@ -107,7 +89,7 @@ export async function GET(request: NextRequest) {
// If utm_campaign is not found or URL parsing fails, use regex as fallback // If utm_campaign is not found or URL parsing fails, use regex as fallback
const campaignMatch = url.match(/[?&]utm_campaign=([^&]+)/i); const campaignMatch = url.match(/[?&]utm_campaign=([^&]+)/i);
if (campaignMatch && campaignMatch[1]) return campaignMatch[1]; if (campaignMatch && campaignMatch[1]) return campaignMatch[1];
} catch { } catch (_) {
// If URL parsing fails, try regex directly // If URL parsing fails, try regex directly
const campaignMatch = url.match(/[?&]utm_campaign=([^&]+)/i); const campaignMatch = url.match(/[?&]utm_campaign=([^&]+)/i);
if (campaignMatch && campaignMatch[1]) return campaignMatch[1]; if (campaignMatch && campaignMatch[1]) return campaignMatch[1];
@@ -136,19 +118,17 @@ export async function GET(request: NextRequest) {
// Determine activity (event_type) // Determine activity (event_type)
const activity = eventWithFullPath.event_type || ''; const activity = eventWithFullPath.event_type || '';
// 修改使用link_label替代visitor_id作为clientId // Client ID (possibly part of visitor_id)
const clientId = eventWithFullPath.link_label || 'undefined'; const clientId = eventWithFullPath.visitor_id?.split('-')[0] || 'undefined';
// Original path - 修正使用link_original_url作为原始URL来源 // Original path (use full URL field)
const originPath = eventWithFullPath.link_original_url || 'undefined'; const originPath = fullUrl || 'undefined';
// Add to CSV content // Add to CSV content
csvContent += `${time},${activity},${campaign},${clientId},${originPath}\n`; csvContent += `${time},${activity},${campaign},${clientId},${originPath}\n`;
}); });
// No need to generate filename since we're not using Content-Disposition header // Return CSV response
// Return CSV response without forcing download
return new NextResponse(csvContent, { return new NextResponse(csvContent, {
headers: { headers: {
'Content-Type': 'text/plain' 'Content-Type': 'text/plain'

View File

@@ -1,18 +1,68 @@
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url); const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get('code'); const code = requestUrl.searchParams.get('code');
if (code) { console.log('Auth callback received:', { url: request.url, hasCode: !!code });
const cookieStore = cookies();
const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); // If no code parameter found, redirect to login page
await supabase.auth.exchangeCodeForSession(code); if (!code) {
console.log('No code parameter found, redirecting to login page');
return NextResponse.redirect(new URL('/login', request.url));
} }
// URL to redirect to after sign in process completes try {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://main.upj.to'; // Create Supabase client
return NextResponse.redirect(new URL('/analytics', siteUrl)); 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)
);
}
} }

View File

@@ -1,45 +0,0 @@
'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

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

View File

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

View File

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

View File

@@ -3,10 +3,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import ProtectedRoute from '@/app/components/ProtectedRoute';
import { limqRequest } from '@/lib/api'; import { limqRequest } from '@/lib/api';
import { TeamSelector } from '@/app/components/ui/TeamSelector'; import { TeamSelector } from '@/app/components/ui/TeamSelector';
import { ProjectSelector } from '@/app/components/ui/ProjectSelector'; import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
import ClientRouteGuard from '@/app/components/ClientRouteGuard';
interface ShortUrlData { interface ShortUrlData {
originalUrl: string; originalUrl: string;
@@ -21,9 +21,9 @@ interface ShortUrlData {
export default function CreateShortUrlPage() { export default function CreateShortUrlPage() {
return ( return (
<ClientRouteGuard> <ProtectedRoute>
<CreateShortUrlForm /> <CreateShortUrlForm />
</ClientRouteGuard> </ProtectedRoute>
); );
} }
@@ -47,7 +47,7 @@ function CreateShortUrlForm() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
// Use useEffect to add user information to form data on load // Use useEffect to add user information to form data when loading
useEffect(() => { useEffect(() => {
if (user) { if (user) {
console.log('Current user:', user.email); console.log('Current user:', user.email);
@@ -114,7 +114,7 @@ function CreateShortUrlForm() {
throw new Error('Domain is required'); throw new Error('Domain is required');
} }
// Construct request data according to API requirements // Build request data according to API requirements
const requestData = { const requestData = {
type: "shorturl", type: "shorturl",
attributes: { attributes: {
@@ -137,7 +137,7 @@ function CreateShortUrlForm() {
// Call API to create shorturl resource // Call API to create shorturl resource
const response = await limqRequest('resource/shorturl', 'POST', requestData as unknown as Record<string, unknown>); const response = await limqRequest('resource/shorturl', 'POST', requestData as unknown as Record<string, unknown>);
console.log('Created successfully:', response); console.log('Creation successful:', response);
setSuccess(true); setSuccess(true);
// Redirect to links list page after 2 seconds // Redirect to links list page after 2 seconds
@@ -207,7 +207,7 @@ function CreateShortUrlForm() {
name="title" name="title"
value={formData.title} value={formData.title}
onChange={handleChange} 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" 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 required
/> />
@@ -265,7 +265,7 @@ function CreateShortUrlForm() {
name="domain" name="domain"
value={formData.domain} value={formData.domain}
onChange={handleChange} 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" 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 required
/> />

168
app/debug/page.tsx Normal file
View File

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

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
@@ -20,20 +20,43 @@ function MessageHandler({ setMessage }: { setMessage: (message: { type: string,
} }
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const searchParams = useSearchParams();
const { signIn, signInWithGoogle, user } = useAuth(); const { signIn, signInWithGoogle, user } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: '', content: '' }); 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(() => { useEffect(() => {
if (user) { if (user) {
router.push('/'); 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);
} }
}, [user, router]); }, [user, redirectUrl]);
const handleEmailSignIn = async (e: React.FormEvent) => { const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -53,10 +76,10 @@ export default function LoginPage() {
const { error } = await signIn(email, password); const { error } = await signIn(email, password);
if (error) { if (error) {
throw new Error(error.message); throw new Error(error instanceof Error ? error.message : 'Unknown error');
} }
// 登录成功后,会通过 useEffect 重定向 // After successful login, redirect via useEffect
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
setMessage({ setMessage({
@@ -75,10 +98,10 @@ export default function LoginPage() {
const { error } = await signInWithGoogle(); const { error } = await signInWithGoogle();
if (error) { if (error) {
throw new Error(error.message); throw new Error(error instanceof Error ? error.message : 'Unknown error');
} }
// Google OAuth will handle the redirect // Google OAuth will redirect the user
} catch (error) { } catch (error) {
console.error('Google login error:', error); console.error('Google login error:', error);
setMessage({ setMessage({
@@ -123,31 +146,7 @@ export default function LoginPage() {
</div> </div>
)} )}
{/* Google Sign In Button */} <form onSubmit={handleEmailSignIn} className="mt-8 space-y-6">
<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> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700"> <label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address Email address
@@ -190,11 +189,38 @@ export default function LoginPage() {
disabled={isLoading} 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" 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 with Email'} {isLoading ? 'Signing in...' : 'Sign in'}
</button> </button>
</div> </div>
</form> </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"> <p className="text-sm mt-6 text-gray-600">
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500"> <Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">

View File

@@ -1,5 +1,39 @@
import { redirect } from 'next/navigation'; 'use client';
import { useEffect } from 'react';
import { useAuth } from '@/lib/auth';
export default function Home() { export default function Home() {
redirect('/analytics'); 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>
);
} }

View File

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

View File

@@ -3,11 +3,12 @@ import type { Database } from "@/types/supabase";
let supabase: SupabaseClient<Database> | null = null; let supabase: SupabaseClient<Database> | null = null;
// 简单的存储适配器使用localStorage // 增强的存储适配器使用localStorage并添加更多错误处理
const storageAdapter = { const storageAdapter = {
getItem: async (key: string) => { getItem: async (key: string) => {
try { try {
const item = localStorage.getItem(key); const item = localStorage.getItem(key);
console.log(`Storage get for key [${key}]: ${item ? "found" : "not found"}`);
return item; return item;
} catch (error) { } catch (error) {
console.error("Storage get error:", error); console.error("Storage get error:", error);
@@ -18,6 +19,7 @@ const storageAdapter = {
setItem: async (key: string, value: string) => { setItem: async (key: string, value: string) => {
try { try {
localStorage.setItem(key, value); localStorage.setItem(key, value);
console.log(`Storage set for key [${key}] successful`);
} catch (error) { } catch (error) {
console.error("Storage set error:", error); console.error("Storage set error:", error);
} }
@@ -26,18 +28,42 @@ const storageAdapter = {
removeItem: async (key: string) => { removeItem: async (key: string) => {
try { try {
localStorage.removeItem(key); localStorage.removeItem(key);
console.log(`Storage remove for key [${key}] successful`);
} catch (error) { } catch (error) {
console.error("Storage remove error:", 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> => { export const getSupabaseClient = (): SupabaseClient<Database> => {
if (!supabase) { if (!supabase) {
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) { 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'); 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>( supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
@@ -47,13 +73,27 @@ export const getSupabaseClient = (): SupabaseClient<Database> => {
storage: storageAdapter, storage: storageAdapter,
persistSession: true, persistSession: true,
autoRefreshToken: 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; return supabase;
}; };
export const clearSupabaseInstance = () => { export const clearSupabaseInstance = () => {
console.log('Clearing Supabase instance');
supabase = null; supabase = null;
}; };

View File

@@ -8,43 +8,132 @@ export interface ApiResponse<T = unknown> {
message?: string; message?: string;
} }
// Common function for authenticated API requests to LIMQ /**
* 通用的LIMQ API请求函数包含重试机制和错误处理
*/
export async function limqRequest<T = unknown>( export async function limqRequest<T = unknown>(
endpoint: string, endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
data?: Record<string, unknown> data?: Record<string, unknown>,
options?: {
retryCount?: number;
retryDelay?: number;
timeout?: number;
}
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
// Get current session // 默认配置
const { data: { session } } = await supabase.auth.getSession(); const retryCount = options?.retryCount ?? 2; // 默认重试2次
const retryDelay = options?.retryDelay ?? 1000; // 默认延迟1秒
const timeout = options?.timeout ?? 10000; // 默认超时10秒
if (!session) { let lastError: Error | null = null;
throw new Error('No active session. User must be authenticated.'); 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;
}
} }
const baseUrl = process.env.NEXT_PUBLIC_LIMQ_API; // 所有重试均失败
const url = `${baseUrl}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`; console.error(`[API] ${method} ${endpoint} 失败,已重试 ${currentRetry}`);
return {
const options: RequestInit = { success: false,
method, error: lastError?.message || '请求失败,请稍后重试'
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,12 +4,11 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Session, User } from '@supabase/supabase-js'; import { Session, User } from '@supabase/supabase-js';
import supabase from './supabase'; import supabase from './supabase';
import { limqRequest } from './api';
// 定义用户类型 // Define user type
export type AuthUser = User | null; export type AuthUser = User | null;
// 定义验证上下文类型 // Define auth context type
export type AuthContextType = { export type AuthContextType = {
user: AuthUser; user: AuthUser;
session: Session | null; session: Session | null;
@@ -21,22 +20,22 @@ export type AuthContextType = {
signOut: () => Promise<void>; signOut: () => Promise<void>;
}; };
// 创建验证上下文 // Create auth context
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
// 验证提供者组件 // Auth provider component
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUser>(null); const [user, setUser] = useState<AuthUser>(null);
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const router = useRouter(); const router = useRouter();
// 初始化验证状态 // Initialize auth state
useEffect(() => { useEffect(() => {
const getSession = async () => { const getSession = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// 尝试从Supabase获取会话 // Try to get session from Supabase
const { data: { session }, error } = await supabase.auth.getSession(); const { data: { session }, error } = await supabase.auth.getSession();
if (error) { if (error) {
@@ -44,6 +43,15 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return; 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); setSession(session);
setUser(session?.user || null); setUser(session?.user || null);
} catch (error) { } catch (error) {
@@ -55,89 +63,98 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
getSession(); getSession();
// 监听验证状态变化 // Listen for auth state changes
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
console.log('Auth state changed, event:', _event);
console.log('New session:', session ? 'Valid' : 'None');
setSession(session); setSession(session);
setUser(session?.user || null); setUser(session?.user || null);
}); });
// 清理函数 // Cleanup function
return () => { return () => {
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, []); }, []);
// 登录函数 // Sign in function
const signIn = async (email: string, password: string) => { const signIn = async (email: string, password: string) => {
setIsLoading(true); setIsLoading(true);
try { try {
console.log('尝试登录:', { email }); console.log('Attempting to sign in:', { email });
// 尝试通过Supabase登录 // Try to sign in with Supabase
const { data, error } = await supabase.auth.signInWithPassword({ const { data, error } = await supabase.auth.signInWithPassword({
email, email,
password, password,
}); });
if (error) { if (error) {
console.error('登录出错:', error); console.error('Sign in error:', error);
return { error }; return { error };
} }
// Sign in successful, set session and user info
console.log('Sign in successful, user:', data.user?.email);
setSession(data.session); setSession(data.session);
setUser(data.user); setUser(data.user);
router.push('/analytics'); // 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);
return {}; return {};
} catch (error) { } catch (error) {
console.error('登录过程出错:', error); console.error('Error during sign in process:', error);
return { error }; return { error };
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// Google登录函数 // Google sign in function
const signInWithGoogle = async () => { const signInWithGoogle = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// 获取网站 URL如果环境变量不存在则使用当前来源 // Try to sign in with Google via Supabase
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || window.location.origin;
// 尝试通过Supabase登录Google
const { error } = await supabase.auth.signInWithOAuth({ const { error } = await supabase.auth.signInWithOAuth({
provider: 'google', provider: 'google',
options: { options: {
redirectTo: `${siteUrl}/auth/callback`, redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline',
prompt: 'consent',
}
}, },
}); });
if (error) { if (error) {
console.error('Google登录出错:', error); console.error('Google sign in error:', error);
return { error }; return { error };
} }
return {}; // Return empty object when successful return {}; // Return empty object when successful
} catch (error) { } catch (error) {
console.error('Google登录过程出错:', error); console.error('Error during Google sign in process:', error);
return { error }; return { error };
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// GitHub登录函数 // GitHub sign in function
const signInWithGitHub = async () => { const signInWithGitHub = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// 获取网站 URL如果环境变量不存在则使用当前来源 // Try to sign in with GitHub via Supabase
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || window.location.origin;
// 尝试通过Supabase登录GitHub
const { error } = await supabase.auth.signInWithOAuth({ const { error } = await supabase.auth.signInWithOAuth({
provider: 'github', provider: 'github',
options: { options: {
redirectTo: `${siteUrl}/auth/callback`, redirectTo: `${window.location.origin}/auth/callback`,
}, },
}); });
@@ -155,52 +172,49 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
}; };
// 注册函数 // Sign up function
const signUp = async (email: string, password: string) => { const signUp = async (email: string, password: string) => {
setIsLoading(true); setIsLoading(true);
try { try {
// 获取网站 URL如果环境变量不存在则使用当前来源 // Try to sign up via Supabase
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || window.location.origin;
// 尝试通过Supabase注册
const { error } = await supabase.auth.signUp({ const { error } = await supabase.auth.signUp({
email, email,
password, password,
options: { options: {
emailRedirectTo: `${siteUrl}/auth/callback`, emailRedirectTo: `${window.location.origin}/auth/callback`,
} }
}); });
if (error) { if (error) {
console.error('注册出错:', error); console.error('Sign up error:', error);
throw 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.'); router.push('/login?message=Registration successful! Please check your email to verify your account before logging in.');
} catch (error) { } catch (error) {
console.error('注册过程出错:', error); console.error('Error during sign up process:', error);
throw error; throw error;
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// 登出函数 // Sign out function
const signOut = async () => { const signOut = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// 尝试通过Supabase登出 // Try to sign out via Supabase
const { error } = await supabase.auth.signOut(); const { error } = await supabase.auth.signOut();
if (error) { if (error) {
console.error('登出出错:', error); console.error('Sign out error:', error);
throw error; throw error;
} }
setSession(null); setSession(null);
setUser(null); setUser(null);
router.push('/login'); router.push('/login');
} catch (error) { } catch (error) {
console.error('登出过程出错:', error); console.error('Error during sign out process:', error);
throw error; throw error;
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -225,7 +239,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
); );
}; };
// 自定义钩子 // Custom hook
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (context === undefined) { if (context === undefined) {
@@ -234,7 +248,7 @@ export const useAuth = () => {
return context; return context;
}; };
// 受保护路由组件 // Protected route component
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -250,7 +264,7 @@ export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ childr
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <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> <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> <p className="mt-4 text-lg text-gray-700 dark:text-gray-300">Loading...</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
// 从环境变量获取Supabase配置 // Get Supabase configuration from environment variables
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || ''; 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 || ''; 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, urlDefined: !!supabaseUrl,
keyDefined: !!supabaseAnonKey, keyDefined: !!supabaseAnonKey,
url: supabaseUrl, url: supabaseUrl,
// 打印部分key以便调试 // Print partial key for debugging
keyPrefix: supabaseAnonKey ? supabaseAnonKey.substring(0, 20) + '...' : 'undefined', keyPrefix: supabaseAnonKey ? supabaseAnonKey.substring(0, 20) + '...' : 'undefined',
keyLength: supabaseAnonKey ? supabaseAnonKey.length : 0 keyLength: supabaseAnonKey ? supabaseAnonKey.length : 0
}); });
@@ -17,7 +17,7 @@ if (!supabaseUrl || !supabaseAnonKey) {
console.error('Supabase URL and Anon Key are required'); console.error('Supabase URL and Anon Key are required');
} }
// 尝试解码JWT token并打印解码内容 // Try to decode JWT token and print decoded content
try { try {
if (supabaseAnonKey) { if (supabaseAnonKey) {
const parts = supabaseAnonKey.split('.'); const parts = supabaseAnonKey.split('.');
@@ -30,19 +30,54 @@ try {
} }
} }
} catch (error) { } catch (error) {
console.error('JWT解码失败:', error); console.error('JWT decoding failed:', error);
} }
// 创建Supabase客户端 // 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
export const supabase = createClient(supabaseUrl, supabaseAnonKey, { export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: { auth: {
persistSession: true, persistSession: true,
autoRefreshToken: true, autoRefreshToken: true,
detectSessionInUrl: true, detectSessionInUrl: true,
storageKey: 'sb-auth-token',
storage: customStorage
} }
}); });
// 测试Supabase连接 // Test Supabase connection
supabase.auth.onAuthStateChange((event, session) => { supabase.auth.onAuthStateChange((event, session) => {
console.log(`Supabase auth event: ${event}`, session ? 'Session exists' : 'No session'); console.log(`Supabase auth event: ${event}`, session ? 'Session exists' : 'No session');
if (session) { if (session) {
@@ -50,7 +85,7 @@ supabase.auth.onAuthStateChange((event, session) => {
} }
}); });
// 尝试执行健康检查 // Try to perform health check
async function checkSupabaseHealth() { async function checkSupabaseHealth() {
try { try {
const { data, error } = await supabase.from('_health').select('*').limit(1); const { data, error } = await supabase.from('_health').select('*').limit(1);

View File

@@ -1,22 +1,80 @@
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
export async function middleware(req: NextRequest) { export function middleware(request: NextRequest) {
const res = NextResponse.next(); // Get the request path
const path = request.nextUrl.pathname;
console.log(`[Middleware] Request path: ${path}`);
// Create a Supabase client configured to use cookies // Define paths that don't require authentication
const supabase = createMiddlewareClient({ req, res }); const publicPaths = ['/login', '/register', '/auth/callback'];
// Refresh session if expired - required for Server Components // API routes don't require authentication
await supabase.auth.getSession(); if (path.startsWith('/api/')) {
console.log('[Middleware] API route, skipping validation');
return NextResponse.next();
}
return res; // 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();
}
// 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();
} }
// Specify the paths where this middleware should run // Configure middleware matching paths
export const config = { export const config = {
matcher: [ matcher: [
// Match all paths, but exclude static resources
'/((?!_next/static|_next/image|favicon.ico).*)', '/((?!_next/static|_next/image|favicon.ico).*)',
// Explicitly include important routes
'/',
'/analytics',
'/links',
'/create-shorturl',
], ],
}; };

View File

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

28
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -1,19 +0,0 @@
{
"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

@@ -1,714 +0,0 @@
// 从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,12 +1,4 @@
// 从MongoDB的trace表同步数据到ClickHouse的events表 // 从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 { getVariable, setVariable } from "npm:windmill-client@1";
import { MongoClient, ObjectId } from "https://deno.land/x/mongo@v0.32.0/mod.ts"; import { MongoClient, ObjectId } from "https://deno.land/x/mongo@v0.32.0/mod.ts";
@@ -76,33 +68,6 @@ interface UtmParams {
// 同步状态键名 // 同步状态键名
const SYNC_STATE_KEY = "f/shorturl_analytics/mongo_sync_state"; 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参数的函数增强版 // 从URL中提取UTM参数的函数增强版
function extractUtmParams(url: string, debug = false): UtmParams { function extractUtmParams(url: string, debug = false): UtmParams {
const defaultUtmParams: UtmParams = { const defaultUtmParams: UtmParams = {
@@ -223,12 +188,7 @@ export async function main(
force_insert = true, force_insert = true,
database_override = "shorturl_analytics", // 添加数据库名称参数默认为shorturl_analytics database_override = "shorturl_analytics", // 添加数据库名称参数默认为shorturl_analytics
reset_sync_state = false, // 添加参数用于重置同步状态 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 logWithTimestamp = (message: string) => {
const now = new Date(); const now = new Date();
@@ -237,34 +197,6 @@ export async function main(
logWithTimestamp("开始执行MongoDB到ClickHouse的同步任务"); logWithTimestamp("开始执行MongoDB到ClickHouse的同步任务");
logWithTimestamp(`批处理大小: ${batch_size}, 最大记录数: ${max_records}, 超时时间: ${timeout_minutes}分钟`); 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) { if (skip_clickhouse_check) {
logWithTimestamp("⚠️ 警告: 已启用跳过ClickHouse检查模式不会检查记录是否已存在"); logWithTimestamp("⚠️ 警告: 已启用跳过ClickHouse检查模式不会检查记录是否已存在");
} }
@@ -277,14 +209,6 @@ export async function main(
if (debug_utm) { if (debug_utm) {
logWithTimestamp("已启用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(); const startTime = Date.now();
@@ -404,31 +328,11 @@ export async function main(
// 构建查询条件,根据上次同步状态获取新记录 // 构建查询条件,根据上次同步状态获取新记录
const query: Record<string, unknown> = { const query: Record<string, unknown> = {
// 删除了 type: 1 的条件,将同步所有数据 type: 1 // 只同步type为1的记录
}; };
// 根据时间范围参数构建查询条件 // 如果有上次同步状态,则只获取更新的记录
if (use_custom_time_range) { if (lastSyncState && lastSyncState.last_sync_time) {
// 使用自定义时间范围
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 }; query.createTime = { $gt: lastSyncState.last_sync_time };
logWithTimestamp(`将只同步createTime > ${lastSyncState.last_sync_time} (${new Date(lastSyncState.last_sync_time).toISOString()}) 的记录`); logWithTimestamp(`将只同步createTime > ${lastSyncState.last_sync_time} (${new Date(lastSyncState.last_sync_time).toISOString()}) 的记录`);
@@ -548,7 +452,7 @@ export async function main(
return { return {
// UUID将由ClickHouse自动生成 (event_id) // UUID将由ClickHouse自动生成 (event_id)
event_time: eventTime.toISOString().replace('T', ' ').replace('Z', ''), event_time: eventTime.toISOString().replace('T', ' ').replace('Z', ''),
event_type: "click", // 将所有event_type都设置为click event_type: record.type === 1 ? "visit" : "custom",
event_attributes: JSON.stringify(eventAttributes), event_attributes: JSON.stringify(eventAttributes),
link_id: record.slugId.toString(), link_id: record.slugId.toString(),
link_slug: shortLink?.slug || "unknown_slug", // 使用占位符 link_slug: shortLink?.slug || "unknown_slug", // 使用占位符
@@ -563,15 +467,15 @@ export async function main(
? new Date(shortLink.expiresAt).toISOString().replace('T', ' ').replace('Z', '') ? new Date(shortLink.expiresAt).toISOString().replace('T', ' ').replace('Z', '')
: null, : null,
link_tags: shortLink?.tags ? JSON.stringify(shortLink.tags) : "[]", link_tags: shortLink?.tags ? JSON.stringify(shortLink.tags) : "[]",
user_id: "3680f452-e404-4339-a3d2-2a8e1ff92102", // 使用占位符 user_id: shortLink?.user || "unknown_user", // 使用占位符
user_name: "unknown_user", // 使用占位符 user_name: "unknown_user", // 使用占位符
user_email: "", user_email: "",
user_attributes: "{}", user_attributes: "{}",
team_id: "e02251eb-eb98-47c8-b5dd-4f6e4fdb1f49", // 使用占位符 team_id: shortLink?.teamId || "unknown_team", // 使用占位符
team_name: "", // 使用占位符 team_name: "unknown_team", // 使用占位符
team_attributes: "{}", team_attributes: "{}",
project_id: "34cdb8b9-8b8e-4033-876a-0632002ef1f9", // 使用占位符 project_id: shortLink?.projectId || "unknown_project", // 使用占位符
project_name: "", // 使用占位符 project_name: "unknown_project", // 使用占位符
project_attributes: "{}", project_attributes: "{}",
qr_code_id: "", qr_code_id: "",
qr_code_name: "", qr_code_name: "",
@@ -611,23 +515,12 @@ export async function main(
referrer, utm_source, utm_medium, utm_campaign, utm_term, utm_content, time_spent_sec, 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) is_bounce, is_qr_scan, conversion_type, conversion_value, req_full_path)
VALUES ${clickhouseData.map(record => { VALUES ${clickhouseData.map(record => {
// 增强版安全替换函数,处理所有特殊字符 // 确保所有字符串值都是字符串类型,并安全处理替换
const safeReplace = (val: unknown): string => { const safeReplace = (val: unknown): string => {
// 确保值是字符串如果是null或undefined则使用空字符串 // 确保值是字符串如果是null或undefined则使用空字符串
const str = val === null || val === undefined ? "" : String(val); const str = val === null || val === undefined ? "" : String(val);
// 安全替换单引号
// 转义所有可能导致SQL注入或格式错误的字符 return str.replace(/'/g, "''");
// 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)}', return `('${record.event_time}', '${safeReplace(record.event_type)}', '${safeReplace(record.event_attributes)}',
@@ -748,25 +641,20 @@ export async function main(
// 更新同步状态 // 更新同步状态
if (processedRecords > 0 && lastSyncTime > 0) { if (processedRecords > 0 && lastSyncTime > 0) {
// 只在非自定义时间范围模式下更新同步状态 // 创建新的同步状态,简化对象结构
if (!use_custom_time_range) { const newSyncState: SyncState = {
// 创建新的同步状态,简化对象结构 last_sync_time: lastSyncTime,
const newSyncState: SyncState = { records_synced: (lastSyncState ? lastSyncState.records_synced : 0) + processedRecords, // 使用处理的总记录数,而不是实际插入数
last_sync_time: lastSyncTime, };
records_synced: (lastSyncState ? lastSyncState.records_synced : 0) + processedRecords, // 使用处理的总记录数,而不是实际插入数
};
try { try {
// 保存同步状态 // 保存同步状态
await setVariable(SYNC_STATE_KEY, newSyncState); await setVariable(SYNC_STATE_KEY, newSyncState);
logWithTimestamp(`同步状态已更新: 最后同步时间 ${new Date(newSyncState.last_sync_time).toISOString()}, 累计同步记录数 ${newSyncState.records_synced}`); logWithTimestamp(`同步状态已更新: 最后同步时间 ${new Date(newSyncState.last_sync_time).toISOString()}, 累计同步记录数 ${newSyncState.records_synced}`);
} catch (err) { } catch (err) {
const error = err as Error; const error = err as Error;
logWithTimestamp(`更新同步状态失败: ${error.message},将继续执行`); logWithTimestamp(`更新同步状态失败: ${error.message},将继续执行`);
// 不抛出错误,继续执行 // 不抛出错误,继续执行
}
} else {
logWithTimestamp("使用自定义时间范围模式,不更新全局同步状态");
} }
} }
@@ -775,8 +663,7 @@ export async function main(
records_processed: processedRecords, records_processed: processedRecords,
records_synced: totalBatchRecords, records_synced: totalBatchRecords,
last_sync_time: lastSyncTime > 0 ? new Date(lastSyncTime).toISOString() : null, last_sync_time: lastSyncTime > 0 ? new Date(lastSyncTime).toISOString() : null,
message: use_custom_time_range ? "自定义时间范围数据同步完成" : "数据同步完成", message: "数据同步完成"
custom_time_range_used: use_custom_time_range
}; };
} catch (err) { } catch (err) {
console.error("同步过程中发生错误:", err); console.error("同步过程中发生错误:", err);

View File

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