diff --git a/app/api/shortlinks/route.ts b/app/api/shortlinks/route.ts
new file mode 100644
index 0000000..198a845
--- /dev/null
+++ b/app/api/shortlinks/route.ts
@@ -0,0 +1,53 @@
+import { NextResponse } from 'next/server';
+import { executeQuery } from '@/lib/clickhouse';
+
+export async function GET() {
+ try {
+ // Query to fetch shorturl data from ClickHouse
+ const query = `
+ SELECT
+ id,
+ external_id,
+ type,
+ slug,
+ original_url,
+ title,
+ description,
+ attributes,
+ schema_version,
+ creator_id,
+ creator_email,
+ creator_name,
+ created_at,
+ updated_at,
+ deleted_at,
+ projects,
+ teams,
+ tags,
+ qr_codes AS qr_codes,
+ channels,
+ favorites,
+ expires_at,
+ click_count,
+ unique_visitors
+ FROM shorturl_analytics.shorturl
+ WHERE deleted_at IS NULL
+ ORDER BY created_at DESC
+ `;
+
+ // Execute the query using the shared client
+ const rows = await executeQuery(query);
+
+ // Return the data
+ return NextResponse.json({
+ links: rows,
+ total: rows.length
+ });
+ } catch (error) {
+ console.error('Error fetching shortlinks from ClickHouse:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch shortlinks' },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/components/layout/Header.tsx b/app/components/layout/Header.tsx
index 29a5e7a..3bceb9b 100644
--- a/app/components/layout/Header.tsx
+++ b/app/components/layout/Header.tsx
@@ -30,6 +30,23 @@ export default function Header() {
ShortURL Analytics
+
+ {user && (
+
+ )}
{user && (
diff --git a/app/ip-test/page.tsx b/app/ip-test/page.tsx
deleted file mode 100644
index abc43d5..0000000
--- a/app/ip-test/page.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import IpLocationTest from '../components/ipLocationTest';
-
-export default function IpTestPage() {
- return (
-
-
IP to Location Test
-
-
- );
-}
\ No newline at end of file
diff --git a/app/links/page.tsx b/app/links/page.tsx
new file mode 100644
index 0000000..e0908c8
--- /dev/null
+++ b/app/links/page.tsx
@@ -0,0 +1,384 @@
+"use client";
+
+import { useEffect, useState } from 'react';
+import { getSupabaseClient } from '../utils/supabase';
+import { AuthChangeEvent } from '@supabase/supabase-js';
+import { Loader2, ExternalLink, Copy, Search } from 'lucide-react';
+
+// Define attribute type to avoid using 'any'
+interface LinkAttributes {
+ title?: string;
+ name?: string;
+ slug?: string;
+ original_url?: string;
+ originalUrl?: string;
+ visits?: number;
+ click_count?: number;
+ team_id?: string;
+ team_name?: string;
+ tags?: string[];
+ [key: string]: unknown;
+}
+
+// Define Link type based on the database schema
+interface ShortLink {
+ id: string;
+ external_id: string | null;
+ attributes: LinkAttributes | null;
+ type: string | null;
+ creator_id: string | null;
+ created_at: string | null;
+ updated_at: string | null;
+ deleted_at: string | null;
+ schema_version: number | null;
+}
+
+// Define ClickHouse shorturl type
+interface ClickHouseShortUrl {
+ id: string;
+ external_id: string;
+ type: string;
+ slug: string;
+ original_url: string;
+ title: string;
+ description: string;
+ attributes: string; // JSON string
+ schema_version: number;
+ creator_id: string;
+ creator_email: string;
+ creator_name: string;
+ created_at: string;
+ updated_at: string;
+ deleted_at: string | null;
+ projects: string; // JSON string
+ teams: string; // JSON string
+ tags: string; // JSON string
+ qr_codes: string; // JSON string
+ channels: string; // JSON string
+ favorites: string; // JSON string
+ expires_at: string | null;
+ click_count: number;
+ unique_visitors: number;
+ link_attributes?: string; // Optional JSON string containing link-specific attributes
+}
+
+// Convert ClickHouse data to ShortLink format
+function convertClickHouseToShortLink(chData: ClickHouseShortUrl): ShortLink {
+ // Parse JSON strings
+ const attributesJson = JSON.parse(chData.attributes || '{}');
+ const teamsJson = JSON.parse(chData.teams || '[]');
+ const tagsJson = JSON.parse(chData.tags || '[]');
+
+ // Extract team info from the first team if available
+ const teamInfo = teamsJson.length > 0 ? {
+ team_id: teamsJson[0].team_id,
+ team_name: teamsJson[0].team_name
+ } : {};
+
+ // Extract tag names
+ const tagNames = tagsJson.map((tag: { tag_name: string }) => tag.tag_name);
+
+ // Parse link_attributes to get domain if available
+ let domain = 'shorturl.example.com';
+ try {
+ if (chData.link_attributes) {
+ const linkAttrObj = JSON.parse(chData.link_attributes);
+ if (linkAttrObj.domain) {
+ domain = linkAttrObj.domain;
+ }
+ }
+
+ // Extract domain from shortUrl in attributes if available
+ const attributesObj = JSON.parse(chData.attributes || '{}');
+ if (attributesObj.shortUrl) {
+ try {
+ const urlObj = new URL(attributesObj.shortUrl);
+ domain = urlObj.hostname;
+ } catch (e) {
+ console.error('Error parsing shortUrl:', e);
+ }
+ }
+ } catch (e) {
+ console.error('Error parsing link_attributes:', e);
+ }
+
+ return {
+ id: chData.id,
+ external_id: chData.external_id,
+ type: chData.type,
+ creator_id: chData.creator_id,
+ created_at: chData.created_at,
+ updated_at: chData.updated_at,
+ deleted_at: chData.deleted_at,
+ schema_version: chData.schema_version,
+ attributes: {
+ ...attributesJson,
+ title: chData.title || attributesJson.title || '',
+ slug: chData.slug,
+ original_url: chData.original_url,
+ click_count: chData.click_count,
+ visits: chData.click_count,
+ unique_visitors: chData.unique_visitors,
+ domain: attributesJson.domain || domain,
+ ...teamInfo,
+ tags: tagNames
+ }
+ };
+}
+
+export default function LinksPage() {
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [links, setLinks] = useState([]);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [teamFilter, setTeamFilter] = useState(null);
+
+ // Copy link to clipboard
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ // Could add a notification here
+ console.log('Copied to clipboard');
+ });
+ };
+
+ // Extract link metadata from attributes
+ const getLinkMetadata = (link: ShortLink) => {
+ const attrs = link.attributes || {};
+ return {
+ title: attrs.title || attrs.name || 'Untitled Link',
+ slug: attrs.slug || 'no-slug',
+ originalUrl: attrs.original_url || attrs.originalUrl || '#',
+ visits: attrs.visits || attrs.click_count || 0,
+ teamId: attrs.team_id || '',
+ teamName: attrs.team_name || 'Personal',
+ createdAt: new Date(link.created_at || Date.now()).toLocaleDateString(),
+ tags: attrs.tags || [],
+ domain: attrs.domain || 'shorturl.example.com',
+ };
+ };
+
+ // Filter links by search query
+ const filteredLinks = links.length > 0 ?
+ links.filter(link => {
+ if (!searchQuery && !teamFilter) return true;
+
+ const metadata = getLinkMetadata(link);
+ const matchesSearch = searchQuery ?
+ (metadata.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ metadata.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ metadata.originalUrl.toLowerCase().includes(searchQuery.toLowerCase())) :
+ true;
+
+ const matchesTeam = teamFilter ?
+ metadata.teamId === teamFilter :
+ true;
+
+ return matchesSearch && matchesTeam;
+ }) : [];
+
+ // Get unique teams for filtering
+ const teams = links.length > 0 ?
+ Array.from(
+ links.reduce((teams, link) => {
+ const metadata = getLinkMetadata(link);
+ if (metadata.teamId) {
+ teams.set(metadata.teamId, metadata.teamName);
+ }
+ return teams;
+ }, new Map())
+ ).map(([id, name]) => ({ id, name })) : [];
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const fetchLinks = async () => {
+ if (!isMounted) return;
+ setLoading(true);
+ setError(null);
+
+ try {
+ // Fetch data from ClickHouse API instead of Supabase
+ const response = await fetch('/api/shortlinks');
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch links: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ if (!data || !data.links || data.links.length === 0) {
+ if (isMounted) setLinks([]);
+ return;
+ }
+
+ // Convert ClickHouse data format to ShortLink format
+ const convertedLinks = data.links.map(convertClickHouseToShortLink);
+
+ if (isMounted) {
+ setLinks(convertedLinks);
+ }
+ } catch (err) {
+ if (isMounted) {
+ setError(err instanceof Error ? err.message : 'Failed to load short URLs');
+ console.error("Error fetching links:", err);
+ }
+ } finally {
+ if (isMounted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ const supabase = getSupabaseClient();
+
+ const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => {
+ if (event === 'SIGNED_IN') {
+ fetchLinks();
+ } else if (event === 'SIGNED_OUT') {
+ setLinks([]);
+ setError(null);
+ }
+ });
+
+ supabase.auth.getSession().then(() => {
+ fetchLinks();
+ });
+
+ return () => {
+ isMounted = false;
+ subscription.unsubscribe();
+ };
+ }, []);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (links.length === 0) {
+ return (
+
+
No short links found
+
Create your first short URL to get started
+
+ );
+ }
+
+ return (
+
+
Short URL Links
+
+ {/* Search and filters */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
+ />
+
+
+
+
+
+ {/* Links table */}
+
+
+
+
+ | Link |
+ Original URL |
+ Team |
+ Created |
+ Visits |
+ Actions |
+
+
+
+ {filteredLinks.map(link => {
+ const metadata = getLinkMetadata(link);
+ const shortUrl = `https://${metadata.domain}/${metadata.slug}`;
+
+ return (
+
+ |
+
+ {metadata.title}
+ {shortUrl}
+
+ |
+
+
+ {metadata.originalUrl}
+
+
+ |
+
+ {metadata.teamName}
+ |
+
+ {metadata.createdAt}
+ |
+
+ {metadata.visits}
+ |
+
+
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ {/* Pagination or "Load More" could be added here */}
+
+ {filteredLinks.length === 0 && links.length > 0 && (
+
+ No links match your search criteria
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/scripts/db/db-reports/clickhouse-schema-2025-03-20T13-57-59-013Z.log b/scripts/db/db-reports/clickhouse-schema-2025-03-20T13-57-59-013Z.log
deleted file mode 100644
index 05c9b73..0000000
--- a/scripts/db/db-reports/clickhouse-schema-2025-03-20T13-57-59-013Z.log
+++ /dev/null
@@ -1,225 +0,0 @@
-
-获取所有表...
-数据库 limq 中找到以下表:
- - .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb
- - .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc
- - .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1
- - .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0
- - .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024
- - .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea
- - link_daily_stats
- - link_events
- - link_hourly_patterns
- - links
- - platform_distribution
- - project_daily_stats
- - projects
- - qr_scans
- - qrcode_daily_stats
- - qrcodes
- - sessions
- - team_daily_stats
- - team_members
- - teams
-
-所有ClickHouse表:
-.inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb, .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc, .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1, .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0, .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024, .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea, link_daily_stats, link_events, link_hourly_patterns, links, platform_distribution, project_daily_stats, projects, qr_scans, qrcode_daily_stats, qrcodes, sessions, team_daily_stats, team_members, teams
-
-获取表 .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb 的结构...
-
-获取表 .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc 的结构...
-
-获取表 .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1 的结构...
-
-获取表 .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0 的结构...
-
-获取表 .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024 的结构...
-
-获取表 .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea 的结构...
-
-获取表 link_daily_stats 的结构...
-表 link_daily_stats 的列:
- - date (Date, 无默认值)
- - link_id (String, 无默认值)
- - total_clicks (UInt64, 无默认值)
- - unique_visitors (UInt64, 无默认值)
- - unique_sessions (UInt64, 无默认值)
- - total_time_spent (UInt64, 无默认值)
- - avg_time_spent (Float64, 无默认值)
- - bounce_count (UInt64, 无默认值)
- - conversion_count (UInt64, 无默认值)
- - unique_referrers (UInt64, 无默认值)
- - mobile_count (UInt64, 无默认值)
- - tablet_count (UInt64, 无默认值)
- - desktop_count (UInt64, 无默认值)
- - qr_scan_count (UInt64, 无默认值)
- - total_conversion_value (Float64, 无默认值)
-
-获取表 link_events 的结构...
-表 link_events 的列:
- - event_id (UUID, 默认值: generateUUIDv4())
- - event_time (DateTime64(3), 默认值: now64())
- - date (Date, 默认值: toDate(event_time))
- - link_id (String, 无默认值)
- - channel_id (String, 无默认值)
- - visitor_id (String, 无默认值)
- - session_id (String, 无默认值)
- - event_type (Enum8('click' = 1, 'redirect' = 2, 'conversion' = 3, 'error' = 4), 无默认值)
- - ip_address (String, 无默认值)
- - country (String, 无默认值)
- - city (String, 无默认值)
- - referrer (String, 无默认值)
- - utm_source (String, 无默认值)
- - utm_medium (String, 无默认值)
- - utm_campaign (String, 无默认值)
- - user_agent (String, 无默认值)
- - device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
- - browser (String, 无默认值)
- - os (String, 无默认值)
- - time_spent_sec (UInt32, 默认值: 0)
- - is_bounce (Bool, 默认值: true)
- - is_qr_scan (Bool, 默认值: false)
- - qr_code_id (String, 默认值: '')
- - conversion_type (Enum8('visit' = 1, 'stay' = 2, 'interact' = 3, 'signup' = 4, 'subscription' = 5, 'purchase' = 6), 默认值: 'visit')
- - conversion_value (Float64, 默认值: 0)
- - custom_data (String, 默认值: '{}')
-
-获取表 link_hourly_patterns 的结构...
-表 link_hourly_patterns 的列:
- - date (Date, 无默认值)
- - hour (UInt8, 无默认值)
- - link_id (String, 无默认值)
- - visits (UInt64, 无默认值)
- - unique_visitors (UInt64, 无默认值)
-
-获取表 links 的结构...
-表 links 的列:
- - link_id (String, 无默认值)
- - original_url (String, 无默认值)
- - created_at (DateTime64(3), 无默认值)
- - created_by (String, 无默认值)
- - title (String, 无默认值)
- - description (String, 无默认值)
- - tags (Array(String), 无默认值)
- - is_active (Bool, 默认值: true)
- - expires_at (Nullable(DateTime), 无默认值)
- - team_id (String, 默认值: '')
- - project_id (String, 默认值: '')
-
-获取表 platform_distribution 的结构...
-表 platform_distribution 的列:
- - date (Date, 无默认值)
- - utm_source (String, 无默认值)
- - device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
- - visits (UInt64, 无默认值)
- - unique_visitors (UInt64, 无默认值)
-
-获取表 project_daily_stats 的结构...
-表 project_daily_stats 的列:
- - date (Date, 无默认值)
- - project_id (String, 无默认值)
- - total_clicks (UInt64, 无默认值)
- - unique_visitors (UInt64, 无默认值)
- - conversion_count (UInt64, 无默认值)
- - links_used (UInt64, 无默认值)
- - qr_scan_count (UInt64, 无默认值)
-
-获取表 projects 的结构...
-表 projects 的列:
- - project_id (String, 无默认值)
- - team_id (String, 无默认值)
- - name (String, 无默认值)
- - created_at (DateTime, 无默认值)
- - created_by (String, 无默认值)
- - description (String, 默认值: '')
- - is_archived (Bool, 默认值: false)
- - links_count (UInt32, 默认值: 0)
- - total_clicks (UInt64, 默认值: 0)
- - last_updated (DateTime, 默认值: now())
-
-获取表 qr_scans 的结构...
-表 qr_scans 的列:
- - scan_id (UUID, 默认值: generateUUIDv4())
- - qr_code_id (String, 无默认值)
- - link_id (String, 无默认值)
- - scan_time (DateTime64(3), 无默认值)
- - visitor_id (String, 无默认值)
- - location (String, 无默认值)
- - device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
- - led_to_conversion (Bool, 默认值: false)
-
-获取表 qrcode_daily_stats 的结构...
-表 qrcode_daily_stats 的列:
- - date (Date, 无默认值)
- - qr_code_id (String, 无默认值)
- - total_scans (UInt64, 无默认值)
- - unique_scanners (UInt64, 无默认值)
- - conversions (UInt64, 无默认值)
- - mobile_scans (UInt64, 无默认值)
- - tablet_scans (UInt64, 无默认值)
- - desktop_scans (UInt64, 无默认值)
- - unique_locations (UInt64, 无默认值)
-
-获取表 qrcodes 的结构...
-表 qrcodes 的列:
- - qr_code_id (String, 无默认值)
- - link_id (String, 无默认值)
- - team_id (String, 无默认值)
- - project_id (String, 默认值: '')
- - name (String, 无默认值)
- - description (String, 默认值: '')
- - created_at (DateTime, 无默认值)
- - created_by (String, 无默认值)
- - updated_at (DateTime, 默认值: now())
- - qr_type (Enum8('standard' = 1, 'custom' = 2, 'dynamic' = 3), 默认值: 'standard')
- - image_url (String, 默认值: '')
- - design_config (String, 默认值: '{}')
- - is_active (Bool, 默认值: true)
- - total_scans (UInt64, 默认值: 0)
- - unique_scanners (UInt32, 默认值: 0)
-
-获取表 sessions 的结构...
-表 sessions 的列:
- - session_id (String, 无默认值)
- - visitor_id (String, 无默认值)
- - link_id (String, 无默认值)
- - started_at (DateTime64(3), 无默认值)
- - last_activity (DateTime64(3), 无默认值)
- - ended_at (Nullable(DateTime64(3)), 无默认值)
- - duration_sec (UInt32, 默认值: 0)
- - session_pages (UInt8, 默认值: 1)
- - is_completed (Bool, 默认值: false)
-
-获取表 team_daily_stats 的结构...
-表 team_daily_stats 的列:
- - date (Date, 无默认值)
- - team_id (String, 无默认值)
- - total_clicks (UInt64, 无默认值)
- - unique_visitors (UInt64, 无默认值)
- - conversion_count (UInt64, 无默认值)
- - links_used (UInt64, 无默认值)
- - qr_scan_count (UInt64, 无默认值)
-
-获取表 team_members 的结构...
-表 team_members 的列:
- - team_id (String, 无默认值)
- - user_id (String, 无默认值)
- - role (Enum8('owner' = 1, 'admin' = 2, 'editor' = 3, 'viewer' = 4), 无默认值)
- - joined_at (DateTime, 默认值: now())
- - invited_by (String, 无默认值)
- - is_active (Bool, 默认值: true)
- - last_active (DateTime, 默认值: now())
-
-获取表 teams 的结构...
-表 teams 的列:
- - team_id (String, 无默认值)
- - name (String, 无默认值)
- - created_at (DateTime, 无默认值)
- - created_by (String, 无默认值)
- - description (String, 默认值: '')
- - avatar_url (String, 默认值: '')
- - is_active (Bool, 默认值: true)
- - plan_type (Enum8('free' = 1, 'pro' = 2, 'enterprise' = 3), 无默认值)
- - members_count (UInt32, 默认值: 1)
-
-ClickHouse数据库结构检查完成
diff --git a/scripts/db/sql/clickhouse/create_shorturl_table.sql b/scripts/db/sql/clickhouse/create_shorturl_table.sql
new file mode 100644
index 0000000..ccd95c4
--- /dev/null
+++ b/scripts/db/sql/clickhouse/create_shorturl_table.sql
@@ -0,0 +1,46 @@
+-- 使用shorturl_analytics数据库
+USE shorturl_analytics;
+
+-- 删除已存在的shorturl表
+DROP TABLE IF EXISTS shorturl_analytics.shorturl;
+
+-- 创建shorturl表
+CREATE TABLE IF NOT EXISTS shorturl_analytics.shorturl (
+ -- 短链接基本信息(来源于resources表)
+ id String COMMENT '资源ID (resources.id)',
+ external_id String COMMENT '外部ID (resources.external_id)',
+ type String COMMENT '类型,值为shorturl',
+ slug String COMMENT '短链接slug (存储在attributes中)',
+ original_url String COMMENT '原始URL (存储在attributes中)',
+ title String COMMENT '标题 (存储在attributes中)',
+ description String COMMENT '描述 (存储在attributes中)',
+ attributes String DEFAULT '{}' COMMENT '资源属性JSON',
+ schema_version Int32 COMMENT 'Schema版本',
+ -- 创建者信息
+ creator_id String COMMENT '创建者ID (resources.creator_id)',
+ creator_email String COMMENT '创建者邮箱 (来自users表)',
+ creator_name String COMMENT '创建者名称 (来自users表)',
+ -- 时间信息
+ created_at DateTime64(3) COMMENT '创建时间 (resources.created_at)',
+ updated_at DateTime64(3) COMMENT '更新时间 (resources.updated_at)',
+ deleted_at Nullable(DateTime64(3)) COMMENT '删除时间 (resources.deleted_at)',
+ -- 项目关联 (project_resources表)
+ projects String DEFAULT '[]' COMMENT '项目关联信息数组。结构: [{project_id: String, project_name: String, project_description: String, assigned_at: DateTime64}]',
+ -- 团队关联 (通过项目关联到团队)
+ teams String DEFAULT '[]' COMMENT '团队关联信息数组。结构: [{team_id: String, team_name: String, team_description: String, via_project_id: String}]',
+ -- 标签关联 (resource_tags表)
+ tags String DEFAULT '[]' COMMENT '标签关联信息数组。结构: [{tag_id: String, tag_name: String, tag_type: String, created_at: DateTime64}]',
+ -- QR码关联 (qr_code表)
+ qr_codes String DEFAULT '[]' COMMENT 'QR码信息数组。结构: [{qr_id: String, scan_count: Int32, url: String, template_name: String, created_at: DateTime64}]',
+ -- 渠道关联 (channel表)
+ channels String DEFAULT '[]' COMMENT '渠道信息数组。结构: [{channel_id: String, channel_name: String, channel_path: String, is_user_created: Boolean}]',
+ -- 收藏关联 (favorite表)
+ favorites String DEFAULT '[]' COMMENT '收藏信息数组。结构: [{favorite_id: String, user_id: String, user_name: String, created_at: DateTime64}]',
+ -- 自定义过期时间 (存储在attributes中)
+ expires_at Nullable(DateTime64(3)) COMMENT '过期时间',
+ -- 统计信息 (分析时聚合计算)
+ click_count UInt32 DEFAULT 0 COMMENT '点击次数',
+ unique_visitors UInt32 DEFAULT 0 COMMENT '唯一访问者数'
+) ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at)
+ORDER BY
+ (id, created_at) SETTINGS index_granularity = 8192 COMMENT '用于存储所有shorturl类型资源的统一表,集成了相关联的项目、团队、标签、QR码、渠道和收藏信息';
\ No newline at end of file
diff --git a/windmill/sync_shorturl_to_clickhouse.ts b/windmill/sync_shorturl_to_clickhouse.ts
new file mode 100644
index 0000000..9268889
--- /dev/null
+++ b/windmill/sync_shorturl_to_clickhouse.ts
@@ -0,0 +1,600 @@
+// Windmill script to sync shorturl data from PostgreSQL to ClickHouse
+// 作者: AI Assistant
+// 创建日期: 2023-10-30
+// 描述: 此脚本从PostgreSQL数据库获取所有shorturl类型的资源及其关联数据,并同步到ClickHouse
+
+import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
+import { getResource, getVariable } from "https://deno.land/x/windmill@v1.183.0/mod.ts";
+
+// 资源属性接口
+interface ResourceAttributes {
+ slug?: string;
+ original_url?: string;
+ originalUrl?: string;
+ title?: string;
+ description?: string;
+ expires_at?: string;
+ expiresAt?: string;
+ [key: string]: unknown;
+}
+
+// ClickHouse配置接口
+interface ChConfig {
+ clickhouse_host: string;
+ clickhouse_port: number;
+ clickhouse_user: string;
+ clickhouse_password: string;
+ clickhouse_url: string;
+}
+
+// PostgreSQL配置接口
+interface PgConfig {
+ host: string;
+ port: number;
+ user: string;
+ password: string;
+ dbname?: string;
+ [key: string]: unknown;
+}
+
+// Windmill函数定义
+export async function main(
+ /** PostgreSQL和ClickHouse同步脚本 */
+ params: {
+ /** 同步的资源数量限制,默认500 */
+ limit?: number;
+ /** 是否包含已删除资源 */
+ includeDeleted?: boolean;
+ /** 是否执行实际写入操作 */
+ dryRun?: boolean;
+ /** 开始时间(ISO格式)*/
+ startTime?: string;
+ /** 结束时间(ISO格式)*/
+ endTime?: string;
+ }
+) {
+ // 设置默认参数
+ const limit = params.limit || 500;
+ const includeDeleted = params.includeDeleted || false;
+ const dryRun = params.dryRun || false;
+ const startTime = params.startTime ? new Date(params.startTime) : undefined;
+ const endTime = params.endTime ? new Date(params.endTime) : undefined;
+
+ console.log(`开始同步PostgreSQL shorturl数据到ClickHouse`);
+ console.log(`参数: limit=${limit}, includeDeleted=${includeDeleted}, dryRun=${dryRun}`);
+ if (startTime) console.log(`开始时间: ${startTime.toISOString()}`);
+ if (endTime) console.log(`结束时间: ${endTime.toISOString()}`);
+
+ // 获取数据库配置
+ console.log("获取PostgreSQL数据库配置...");
+ const pgConfig = await getResource('f/limq/postgresql') as PgConfig;
+ console.log(`数据库连接配置: host=${pgConfig.host}, port=${pgConfig.port}, database=${pgConfig.dbname || 'postgres'}, user=${pgConfig.user}`);
+
+ let pgPool: Pool | null = null;
+
+ try {
+ console.log("创建PostgreSQL连接池...");
+
+ pgPool = new Pool({
+ hostname: pgConfig.host,
+ port: pgConfig.port,
+ user: pgConfig.user,
+ password: pgConfig.password,
+ database: pgConfig.dbname || 'postgres'
+ }, 3);
+
+ console.log("PostgreSQL连接池创建完成,尝试连接...");
+
+ // 测试连接
+ const client = await pgPool.connect();
+ try {
+ console.log("连接成功,执行测试查询...");
+ const testResult = await client.queryObject(`SELECT 1 AS test`);
+ console.log(`测试查询结果: ${JSON.stringify(testResult.rows)}`);
+ } finally {
+ client.release();
+ }
+
+ // 获取所有shorturl类型的资源
+ const shorturls = await fetchShorturlResources(pgPool, {
+ limit,
+ includeDeleted,
+ startTime,
+ endTime,
+ });
+
+ console.log(`获取到 ${shorturls.length} 个shorturl资源`);
+
+ if (shorturls.length === 0) {
+ return { synced: 0, message: "没有找到需要同步的shorturl资源" };
+ }
+
+ // 为每个资源获取关联数据
+ const enrichedShorturls = await enrichShorturlData(pgPool, shorturls);
+ console.log(`已丰富 ${enrichedShorturls.length} 个shorturl资源的关联数据`);
+
+ // 转换为ClickHouse格式
+ const clickhouseData = formatForClickhouse(enrichedShorturls);
+
+ if (!dryRun) {
+ // 写入ClickHouse
+ const inserted = await insertToClickhouse(clickhouseData);
+ console.log(`成功写入 ${inserted} 条记录到ClickHouse`);
+ return { synced: inserted, message: "同步完成" };
+ } else {
+ console.log("Dry run模式 - 不执行实际写入");
+ console.log(`将写入 ${clickhouseData.length} 条记录到ClickHouse`);
+ // 输出示例数据
+ if (clickhouseData.length > 0) {
+ console.log("示例数据:");
+ console.log(JSON.stringify(clickhouseData[0], null, 2));
+ }
+ return { synced: 0, dryRun: true, sampleData: clickhouseData.slice(0, 1) };
+ }
+ } catch (error: unknown) {
+ console.error(`同步过程中发生错误: ${(error as Error).message}`);
+ console.error(`错误类型: ${(error as Error).name}`);
+ if ((error as Error).stack) {
+ console.error(`错误堆栈: ${(error as Error).stack}`);
+ }
+ throw error;
+ } finally {
+ if (pgPool) {
+ await pgPool.end();
+ console.log("PostgreSQL连接池已关闭");
+ }
+ }
+}
+
+// 从PostgreSQL获取所有shorturl资源
+async function fetchShorturlResources(
+ pgPool: Pool,
+ options: {
+ limit: number;
+ includeDeleted: boolean;
+ startTime?: Date;
+ endTime?: Date;
+ }
+) {
+ let query = `
+ SELECT
+ r.id,
+ r.external_id,
+ r.type,
+ r.attributes,
+ r.schema_version,
+ r.creator_id,
+ r.created_at,
+ r.updated_at,
+ r.deleted_at,
+ u.email as creator_email,
+ u.first_name as creator_first_name,
+ u.last_name as creator_last_name
+ FROM
+ limq.resources r
+ LEFT JOIN
+ limq.users u ON r.creator_id = u.id
+ WHERE
+ r.type = 'shorturl'
+ `;
+
+ const params = [];
+ let paramCount = 1;
+
+ if (!options.includeDeleted) {
+ query += ` AND r.deleted_at IS NULL`;
+ }
+
+ if (options.startTime) {
+ query += ` AND r.created_at >= $${paramCount}`;
+ params.push(options.startTime);
+ paramCount++;
+ }
+
+ if (options.endTime) {
+ query += ` AND r.created_at <= $${paramCount}`;
+ params.push(options.endTime);
+ paramCount++;
+ }
+
+ query += ` ORDER BY r.created_at DESC LIMIT $${paramCount}`;
+ params.push(options.limit);
+
+ const client = await pgPool.connect();
+ try {
+ const result = await client.queryObject(query, params);
+
+ // 添加调试日志 - 显示获取的数据样本
+ if (result.rows.length > 0) {
+ console.log(`获取到 ${result.rows.length} 条shorturl记录`);
+ console.log(`第一条记录ID: ${result.rows[0].id}`);
+ console.log(`attributes类型: ${typeof result.rows[0].attributes}`);
+ console.log(`attributes内容示例: ${JSON.stringify(String(result.rows[0].attributes)).substring(0, 100)}...`);
+ }
+
+ return result.rows;
+ } finally {
+ client.release();
+ }
+}
+
+// 为每个shorturl资源获取关联数据
+async function enrichShorturlData(pgPool: Pool, shorturls: Record[]) {
+ const client = await pgPool.connect();
+ const enriched = [];
+
+ try {
+ for (const shorturl of shorturls) {
+ // 1. 获取项目关联
+ const projectsResult = await client.queryObject(`
+ SELECT
+ pr.resource_id, pr.project_id,
+ p.name as project_name, p.description as project_description,
+ pr.assigned_at
+ FROM
+ limq.project_resources pr
+ JOIN
+ limq.projects p ON pr.project_id = p.id
+ WHERE
+ pr.resource_id = $1
+ `, [shorturl.id]);
+
+ // 2. 获取团队关联(通过项目)
+ const teamIds = projectsResult.rows.map((p: Record) => p.project_id);
+ const teamsResult = teamIds.length > 0 ? await client.queryObject(`
+ SELECT
+ tp.team_id, tp.project_id,
+ t.name as team_name, t.description as team_description
+ FROM
+ limq.team_projects tp
+ JOIN
+ limq.teams t ON tp.team_id = t.id
+ WHERE
+ tp.project_id = ANY($1::uuid[])
+ `, [teamIds]) : { rows: [] };
+
+ // 3. 获取标签关联
+ const tagsResult = await client.queryObject(`
+ SELECT
+ rt.resource_id, rt.tag_id, rt.created_at,
+ t.name as tag_name, t.type as tag_type
+ FROM
+ limq.resource_tags rt
+ JOIN
+ limq.tags t ON rt.tag_id = t.id
+ WHERE
+ rt.resource_id = $1
+ `, [shorturl.id]);
+
+ // 4. 获取QR码关联
+ const qrCodesResult = await client.queryObject(`
+ SELECT
+ id as qr_id, scan_count, url, template_name, created_at
+ FROM
+ limq.qr_code
+ WHERE
+ resource_id = $1
+ `, [shorturl.id]);
+
+ // 5. 获取渠道关联
+ const channelsResult = await client.queryObject(`
+ SELECT
+ id as channel_id, name as channel_name, path as channel_path,
+ "isUserCreated" as is_user_created
+ FROM
+ limq.channel
+ WHERE
+ "shortUrlId" = $1
+ `, [shorturl.id]);
+
+ // 6. 获取收藏关联
+ const favoritesResult = await client.queryObject(`
+ SELECT
+ f.id as favorite_id, f.user_id, f.created_at,
+ u.first_name, u.last_name
+ FROM
+ limq.favorite f
+ JOIN
+ limq.users u ON f.user_id = u.id
+ WHERE
+ f.favoritable_id = $1 AND f.favoritable_type = 'resource'
+ `, [shorturl.id]);
+
+ // 调试日志
+ console.log(`\n处理资源ID: ${shorturl.id}`);
+ console.log(`attributes类型: ${typeof shorturl.attributes}`);
+
+ // 改进的attributes解析逻辑
+ let attributes: ResourceAttributes = {};
+ try {
+ if (typeof shorturl.attributes === 'string') {
+ // 如果是字符串,尝试解析为JSON
+ console.log(`尝试解析attributes字符串,长度: ${shorturl.attributes.length}`);
+ attributes = JSON.parse(shorturl.attributes);
+ } else if (typeof shorturl.attributes === 'object' && shorturl.attributes !== null) {
+ // 如果已经是对象,直接使用
+ console.log('attributes已经是对象类型');
+ attributes = shorturl.attributes as ResourceAttributes;
+ } else {
+ console.log(`无效的attributes类型: ${typeof shorturl.attributes}`);
+ attributes = {};
+ }
+ } catch (err) {
+ const error = err as Error;
+ console.warn(`无法解析资源 ${shorturl.id} 的attributes JSON:`, error.message);
+ // 尝试进行更多原始数据分析
+ if (typeof shorturl.attributes === 'string') {
+ console.log(`原始字符串前100字符: ${shorturl.attributes.substring(0, 100)}`);
+ }
+ attributes = {};
+ }
+
+ // 尝试从QR码获取数据
+ let slugFromQr = "";
+ const urlFromQr = "";
+
+ if (qrCodesResult.rows.length > 0 && qrCodesResult.rows[0].url) {
+ const qrUrl = qrCodesResult.rows[0].url as string;
+ console.log(`找到QR码URL: ${qrUrl}`);
+
+ try {
+ const urlParts = qrUrl.split('/');
+ slugFromQr = urlParts[urlParts.length - 1];
+ console.log(`从QR码URL提取的slug: ${slugFromQr}`);
+ } catch (err) {
+ const error = err as Error;
+ console.log('无法从QR码URL提取slug:', error.message);
+ }
+ }
+
+ // 日志输出实际字段值
+ console.log(`提取字段 - name: ${attributes.name || 'N/A'}, slug: ${attributes.slug || 'N/A'}`);
+ console.log(`提取字段 - originalUrl: ${attributes.originalUrl || 'N/A'}, original_url: ${attributes.original_url || 'N/A'}`);
+
+ // 整合所有数据
+ const slug = attributes.slug || attributes.name || slugFromQr || "";
+ const originalUrl = attributes.originalUrl || attributes.original_url || urlFromQr || "";
+
+ console.log(`最终使用的slug: ${slug}`);
+ console.log(`最终使用的originalUrl: ${originalUrl}`);
+
+ enriched.push({
+ ...shorturl,
+ attributes,
+ projects: projectsResult.rows,
+ teams: teamsResult.rows,
+ tags: tagsResult.rows,
+ qrCodes: qrCodesResult.rows,
+ channels: channelsResult.rows,
+ favorites: favoritesResult.rows,
+ // 从attributes中提取特定字段 - 使用改进的顺序和QR码备选
+ slug,
+ originalUrl,
+ title: attributes.title || "",
+ description: attributes.description || "",
+ expiresAt: attributes.expires_at || attributes.expiresAt || null
+ });
+ }
+ } finally {
+ client.release();
+ }
+
+ return enriched;
+}
+
+// 将PostgreSQL数据格式化为ClickHouse格式
+function formatForClickhouse(shorturls: Record[]) {
+ // 将日期格式化为ClickHouse兼容的DateTime64(3)格式
+ const formatDateTime = (date: Date | string | number | null | undefined): string | null => {
+ if (!date) return null;
+ // 转换为Date对象
+ const dateObj = date instanceof Date ? date : new Date(date);
+ // 返回格式化的字符串: YYYY-MM-DD HH:MM:SS.SSS
+ return dateObj.toISOString().replace('T', ' ').replace('Z', '');
+ };
+
+ console.log(`\n准备格式化 ${shorturls.length} 条记录为ClickHouse格式`);
+
+ return shorturls.map(shorturl => {
+ // 调试日志:输出关键字段
+ console.log(`处理资源: ${shorturl.id}`);
+ console.log(`slug: ${shorturl.slug || 'EMPTY'}`);
+ console.log(`originalUrl: ${shorturl.originalUrl || 'EMPTY'}`);
+
+ // 记录attributes状态
+ const attributesStr = JSON.stringify(shorturl.attributes || {});
+ const attributesPrev = attributesStr.length > 100 ?
+ attributesStr.substring(0, 100) + '...' :
+ attributesStr;
+ console.log(`attributes: ${attributesPrev}`);
+
+ const creatorName = [shorturl.creator_first_name, shorturl.creator_last_name]
+ .filter(Boolean)
+ .join(" ");
+
+ // 格式化项目数据为JSON数组
+ const projects = JSON.stringify((shorturl.projects as Record[]).map((p) => ({
+ project_id: p.project_id,
+ project_name: p.project_name,
+ project_description: p.project_description,
+ assigned_at: p.assigned_at
+ })));
+
+ // 格式化团队数据为JSON数组
+ const teams = JSON.stringify((shorturl.teams as Record[]).map((t) => ({
+ team_id: t.team_id,
+ team_name: t.team_name,
+ team_description: t.team_description,
+ via_project_id: t.project_id
+ })));
+
+ // 格式化标签数据为JSON数组
+ const tags = JSON.stringify((shorturl.tags as Record[]).map((t) => ({
+ tag_id: t.tag_id,
+ tag_name: t.tag_name,
+ tag_type: t.tag_type,
+ created_at: t.created_at
+ })));
+
+ // 格式化QR码数据为JSON数组
+ const qrCodes = JSON.stringify((shorturl.qrCodes as Record[]).map((q) => ({
+ qr_id: q.qr_id,
+ scan_count: q.scan_count,
+ url: q.url,
+ template_name: q.template_name,
+ created_at: q.created_at
+ })));
+
+ // 格式化渠道数据为JSON数组
+ const channels = JSON.stringify((shorturl.channels as Record[]).map((c) => ({
+ channel_id: c.channel_id,
+ channel_name: c.channel_name,
+ channel_path: c.channel_path,
+ is_user_created: c.is_user_created
+ })));
+
+ // 格式化收藏数据为JSON数组
+ const favorites = JSON.stringify((shorturl.favorites as Record[]).map((f) => ({
+ favorite_id: f.favorite_id,
+ user_id: f.user_id,
+ user_name: `${f.first_name || ""} ${f.last_name || ""}`.trim(),
+ created_at: f.created_at
+ })));
+
+ // 统计信息(可通过events表聚合或在其他地方设置)
+ const clickCount = (shorturl.attributes as ResourceAttributes).click_count as number || 0;
+ const uniqueVisitors = 0;
+
+ // 返回ClickHouse格式数据
+ return {
+ id: shorturl.id,
+ external_id: shorturl.external_id || "",
+ type: shorturl.type,
+ slug: shorturl.slug || "",
+ original_url: shorturl.originalUrl || "",
+ title: shorturl.title || "",
+ description: shorturl.description || "",
+ attributes: JSON.stringify(shorturl.attributes || {}),
+ schema_version: shorturl.schema_version || 1,
+ creator_id: shorturl.creator_id || "",
+ creator_email: shorturl.creator_email || "",
+ creator_name: creatorName,
+ created_at: formatDateTime(shorturl.created_at as Date),
+ updated_at: formatDateTime(shorturl.updated_at as Date),
+ deleted_at: formatDateTime(shorturl.deleted_at as Date | null),
+ projects,
+ teams,
+ tags,
+ qr_codes: qrCodes,
+ channels,
+ favorites,
+ expires_at: formatDateTime(shorturl.expiresAt as Date | null),
+ click_count: clickCount,
+ unique_visitors: uniqueVisitors
+ };
+ });
+}
+
+// 获取ClickHouse配置
+async function getClickHouseConfig(): Promise {
+ try {
+ // 使用getVariable而不是getResource获取ClickHouse配置
+ const chConfigJson = await getVariable("f/shorturl_analytics/clickhouse");
+ console.log("原始ClickHouse配置:", typeof chConfigJson);
+
+ // 确保配置不为空
+ if (!chConfigJson) {
+ throw new Error("未找到ClickHouse配置");
+ }
+
+ // 解析JSON字符串为对象
+ let chConfig: ChConfig;
+ if (typeof chConfigJson === 'string') {
+ try {
+ chConfig = JSON.parse(chConfigJson);
+ } catch (parseError) {
+ console.error("解析JSON失败:", parseError);
+ throw new Error("ClickHouse配置不是有效的JSON");
+ }
+ } else {
+ chConfig = chConfigJson as ChConfig;
+ }
+
+ // 验证配置
+ if (!chConfig.clickhouse_url && chConfig.clickhouse_host && chConfig.clickhouse_port) {
+ chConfig.clickhouse_url = `http://${chConfig.clickhouse_host}:${chConfig.clickhouse_port}`;
+ console.log(`已构建ClickHouse URL: ${chConfig.clickhouse_url}`);
+ }
+
+ if (!chConfig.clickhouse_url) {
+ throw new Error("ClickHouse配置缺少URL");
+ }
+
+ return chConfig;
+ } catch (error) {
+ console.error("获取ClickHouse配置失败:", error);
+ throw error;
+ }
+}
+
+// 写入数据到ClickHouse
+async function insertToClickhouse(data: Record[]) {
+ if (data.length === 0) return 0;
+
+ // 获取ClickHouse连接信息
+ const chConfig = await getClickHouseConfig();
+
+ // 确保URL有效
+ if (!chConfig.clickhouse_url) {
+ throw new Error("无效的ClickHouse URL: 未定义");
+ }
+
+ console.log(`准备写入数据到ClickHouse: ${chConfig.clickhouse_url}`);
+
+ // 构建INSERT查询
+ const columns = Object.keys(data[0]).join(", ");
+
+ const query = `
+ INSERT INTO shorturl_analytics.shorturl (${columns})
+ FORMAT JSONEachRow
+ `;
+
+ // 批量插入
+ let inserted = 0;
+ const batchSize = 100;
+
+ for (let i = 0; i < data.length; i += batchSize) {
+ const batch = data.slice(i, i + batchSize);
+
+ // 使用JSONEachRow格式
+ const rows = batch.map(row => JSON.stringify(row)).join('\n');
+
+ // 使用HTTP接口执行查询
+ try {
+ console.log(`正在发送请求到: ${chConfig.clickhouse_url}`);
+ console.log(`认证信息: ${chConfig.clickhouse_user}:***`);
+
+ const response = await fetch(chConfig.clickhouse_url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Authorization": `Basic ${btoa(`${chConfig.clickhouse_user}:${chConfig.clickhouse_password}`)}`
+ },
+ body: `${query}\n${rows}`,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`ClickHouse插入失败: ${errorText}`);
+ }
+
+ inserted += batch.length;
+ console.log(`已插入 ${inserted}/${data.length} 条记录`);
+ } catch (error) {
+ console.error(`请求ClickHouse时出错:`, error);
+ throw error;
+ }
+ }
+
+ return inserted;
+}
\ No newline at end of file