diff --git a/api/router.go b/api/router.go index 2bb25ab..41a8c7f 100644 --- a/api/router.go +++ b/api/router.go @@ -1,6 +1,7 @@ package api import ( + "gitea.com/texpixel/document_ai/api/v1/analytics" "gitea.com/texpixel/document_ai/api/v1/formula" "gitea.com/texpixel/document_ai/api/v1/oss" "gitea.com/texpixel/document_ai/api/v1/task" @@ -47,6 +48,13 @@ func SetupRouter(engine *gin.RouterGroup) { userRouter.GET("/info", common.MustAuthMiddleware(), userEndpoint.GetUserInfo) } } + + // 数据埋点路由 + analyticsRouter := v1.Group("/analytics", common.GetAuthMiddleware()) + { + analyticsHandler := analytics.NewAnalyticsHandler() + analyticsRouter.POST("/track", analyticsHandler.TrackEvent) + } } } diff --git a/api/v1/analytics/handler.go b/api/v1/analytics/handler.go new file mode 100644 index 0000000..e767f6d --- /dev/null +++ b/api/v1/analytics/handler.go @@ -0,0 +1,47 @@ +package analytics + +import ( + "net/http" + + "gitea.com/texpixel/document_ai/internal/model/analytics" + "gitea.com/texpixel/document_ai/internal/service" + "gitea.com/texpixel/document_ai/pkg/common" + "gitea.com/texpixel/document_ai/pkg/log" + "github.com/gin-gonic/gin" +) + +type AnalyticsHandler struct { + analyticsService *service.AnalyticsService +} + +func NewAnalyticsHandler() *AnalyticsHandler { + return &AnalyticsHandler{ + analyticsService: service.NewAnalyticsService(), + } +} + +// TrackEvent 记录单个事件 +// @Summary 记录单个埋点事件 +// @Description 记录用户行为埋点事件 +// @Tags Analytics +// @Accept json +// @Produce json +// @Param request body analytics.TrackEventRequest true "事件信息" +// @Success 200 {object} common.Response +// @Router /api/v1/analytics/track [post] +func (h *AnalyticsHandler) TrackEvent(c *gin.Context) { + var req analytics.TrackEventRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Error(c.Request.Context(), "bind request failed", "error", err) + c.JSON(http.StatusOK, common.ErrorResponse(c, common.CodeParamError, "invalid request")) + return + } + + if err := h.analyticsService.TrackEvent(c.Request.Context(), &req); err != nil { + log.Error(c.Request.Context(), "track event failed", "error", err) + c.JSON(http.StatusOK, common.ErrorResponse(c, common.CodeSystemError, "failed to track event")) + return + } + + c.JSON(http.StatusOK, common.SuccessResponse(c, "success")) +} diff --git a/frontend-sdk/analytics.ts b/frontend-sdk/analytics.ts new file mode 100644 index 0000000..187d080 --- /dev/null +++ b/frontend-sdk/analytics.ts @@ -0,0 +1,308 @@ +// Analytics SDK for Frontend +// 前端数据埋点 SDK + +interface EventProperties { + [key: string]: any; +} + +interface DeviceInfo { + user_agent?: string; + screen_width?: number; + screen_height?: number; + language?: string; + timezone?: string; + platform?: string; +} + +interface MetaData { + task_id?: string | number; + [key: string]: any; +} + +interface TrackEventParams { + event_name: string; + properties?: EventProperties; + device_info?: DeviceInfo; + meta_data?: MetaData; +} + +interface AnalyticsConfig { + apiUrl: string; + token?: string; + userId?: number | string; + enableAutoTrack?: boolean; + debug?: boolean; +} + +class Analytics { + private config: AnalyticsConfig; + private userId: number | string | null = null; + private eventQueue: TrackEventParams[] = []; + private isSending: boolean = false; + + constructor(config: AnalyticsConfig) { + this.config = { + enableAutoTrack: true, + debug: false, + ...config, + }; + + if (this.config.userId) { + this.userId = this.config.userId; + } + + // 自动收集设备信息 + if (this.config.enableAutoTrack) { + this.initAutoTrack(); + } + } + + /** + * 设置用户ID + */ + setUserId(userId: number | string) { + this.userId = userId; + } + + /** + * 获取设备信息 + */ + private getDeviceInfo(): DeviceInfo { + return { + user_agent: navigator.userAgent, + screen_width: window.screen.width, + screen_height: window.screen.height, + language: navigator.language, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + platform: navigator.platform, + }; + } + + /** + * 记录单个事件 + */ + async track(params: TrackEventParams): Promise { + if (!this.userId) { + console.warn('Analytics: userId not set, event will not be tracked'); + return; + } + + const eventData = { + user_id: this.userId, + event_name: params.event_name, + properties: params.properties || {}, + device_info: { + ...this.getDeviceInfo(), + ...params.device_info, + }, + meta_data: { + timestamp: Date.now(), + ...params.meta_data, + }, + }; + + if (this.config.debug) { + console.log('Analytics Track:', eventData); + } + + try { + const response = await fetch(`${this.config.apiUrl}/track`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.config.token && { Authorization: `Bearer ${this.config.token}` }), + }, + body: JSON.stringify(eventData), + }); + + if (!response.ok) { + throw new Error(`Failed to track event: ${response.statusText}`); + } + + if (this.config.debug) { + console.log('Analytics: Event tracked successfully'); + } + } catch (error) { + console.error('Analytics: Failed to track event', error); + // 失败时加入队列,稍后重试 + this.eventQueue.push(params); + } + } + + /** + * 批量记录事件 + */ + async trackBatch(events: TrackEventParams[]): Promise { + if (!this.userId) { + console.warn('Analytics: userId not set, events will not be tracked'); + return; + } + + const batchData = { + events: events.map((params) => ({ + user_id: this.userId, + event_name: params.event_name, + properties: params.properties || {}, + device_info: { + ...this.getDeviceInfo(), + ...params.device_info, + }, + meta_data: { + timestamp: Date.now(), + ...params.meta_data, + }, + })), + }; + + if (this.config.debug) { + console.log('Analytics Track Batch:', batchData); + } + + try { + const response = await fetch(`${this.config.apiUrl}/track/batch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.config.token && { Authorization: `Bearer ${this.config.token}` }), + }, + body: JSON.stringify(batchData), + }); + + if (!response.ok) { + throw new Error(`Failed to track batch events: ${response.statusText}`); + } + + if (this.config.debug) { + console.log('Analytics: Batch events tracked successfully'); + } + } catch (error) { + console.error('Analytics: Failed to track batch events', error); + } + } + + /** + * 页面浏览事件 + */ + trackPageView(pageName?: string) { + this.track({ + event_name: 'page_view', + properties: { + page_url: window.location.href, + page_title: document.title, + page_name: pageName || document.title, + referrer: document.referrer, + }, + }); + } + + /** + * 点击事件 + */ + trackClick(elementName: string, properties?: EventProperties) { + this.track({ + event_name: 'click', + properties: { + element_name: elementName, + page_url: window.location.href, + ...properties, + }, + }); + } + + /** + * 表单提交事件 + */ + trackFormSubmit(formName: string, properties?: EventProperties) { + this.track({ + event_name: 'form_submit', + properties: { + form_name: formName, + page_url: window.location.href, + ...properties, + }, + }); + } + + /** + * 任务相关事件 + */ + trackTask(taskId: string | number, action: string, properties?: EventProperties) { + this.track({ + event_name: `task_${action}`, + properties: { + action, + ...properties, + }, + meta_data: { + task_id: taskId, + }, + }); + } + + /** + * 初始化自动埋点 + */ + private initAutoTrack() { + // 页面加载完成时记录 + if (document.readyState === 'complete') { + this.trackPageView(); + } else { + window.addEventListener('load', () => this.trackPageView()); + } + + // 页面离开前发送队列中的事件 + window.addEventListener('beforeunload', () => { + if (this.eventQueue.length > 0) { + this.flushQueue(); + } + }); + + // 页面可见性变化 + document.addEventListener('visibilitychange', () => { + if (document.hidden && this.eventQueue.length > 0) { + this.flushQueue(); + } + }); + } + + /** + * 刷新队列中的事件 + */ + private flushQueue() { + if (this.isSending || this.eventQueue.length === 0) { + return; + } + + this.isSending = true; + const eventsToSend = [...this.eventQueue]; + this.eventQueue = []; + + this.trackBatch(eventsToSend).finally(() => { + this.isSending = false; + }); + } + + /** + * 手动刷新队列 + */ + flush() { + this.flushQueue(); + } +} + +// 导出单例实例 +let analyticsInstance: Analytics | null = null; + +export function initAnalytics(config: AnalyticsConfig): Analytics { + analyticsInstance = new Analytics(config); + return analyticsInstance; +} + +export function getAnalytics(): Analytics { + if (!analyticsInstance) { + throw new Error('Analytics not initialized. Call initAnalytics first.'); + } + return analyticsInstance; +} + +export default Analytics; diff --git a/frontend-sdk/usage-examples.ts b/frontend-sdk/usage-examples.ts new file mode 100644 index 0000000..cc67b0c --- /dev/null +++ b/frontend-sdk/usage-examples.ts @@ -0,0 +1,217 @@ +// Analytics SDK 使用示例 + +import { initAnalytics, getAnalytics } from './analytics'; + +// 1. 初始化 SDK +const analytics = initAnalytics({ + apiUrl: 'https://your-api-domain.com/doc_ai/v1/analytics', + token: 'your-auth-token', // 从登录后获取 + userId: 12345, // 用户ID + enableAutoTrack: true, // 启用自动埋点(页面浏览等) + debug: true, // 开发环境下启用调试 +}); + +// 2. 设置用户ID(登录后) +analytics.setUserId(12345); + +// 3. 记录页面浏览 +analytics.trackPageView('首页'); + +// 4. 记录点击事件 +const handleButtonClick = () => { + analytics.trackClick('提交按钮', { + button_text: '提交', + button_position: 'bottom', + }); +}; + +// 5. 记录表单提交 +const handleFormSubmit = (formData: any) => { + analytics.trackFormSubmit('用户注册表单', { + form_fields: Object.keys(formData), + success: true, + }); +}; + +// 6. 记录任务相关事件 +const handleTaskCreate = (taskId: string) => { + analytics.trackTask(taskId, 'create', { + task_type: 'formula_recognition', + file_type: 'image/png', + }); +}; + +const handleTaskComplete = (taskId: string) => { + analytics.trackTask(taskId, 'complete', { + duration_seconds: 5.2, + success: true, + }); +}; + +// 7. 记录自定义事件 +const handleFileUpload = (file: File) => { + analytics.track({ + event_name: 'file_upload', + properties: { + file_name: file.name, + file_size: file.size, + file_type: file.type, + }, + meta_data: { + upload_source: 'drag_drop', + }, + }); +}; + +// 8. 批量记录事件 +const handleBatchActions = () => { + analytics.trackBatch([ + { + event_name: 'button_click', + properties: { button_name: 'save' }, + }, + { + event_name: 'data_export', + properties: { format: 'pdf' }, + }, + ]); +}; + +// 9. React 组件中使用 +import React, { useEffect } from 'react'; + +function HomePage() { + useEffect(() => { + // 页面加载时记录 + getAnalytics().trackPageView('首页'); + }, []); + + const handleClick = () => { + getAnalytics().trackClick('首页-开始按钮'); + }; + + return ( +
+

首页

+ +
+ ); +} + +// 10. Vue 组件中使用 +export default { + name: 'HomePage', + mounted() { + getAnalytics().trackPageView('首页'); + }, + methods: { + handleClick() { + getAnalytics().trackClick('首页-开始按钮'); + }, + }, +}; + +// 11. 记录用户行为流程 +class FormulaRecognitionFlow { + private analytics = getAnalytics(); + private taskId: string | null = null; + + // 开始识别流程 + startRecognition(file: File) { + this.analytics.track({ + event_name: 'formula_recognition_start', + properties: { + file_name: file.name, + file_size: file.size, + }, + }); + } + + // 上传成功 + uploadSuccess(taskId: string) { + this.taskId = taskId; + this.analytics.trackTask(taskId, 'upload_success', { + step: 'upload', + }); + } + + // 识别进行中 + recognitionProcessing() { + if (this.taskId) { + this.analytics.trackTask(this.taskId, 'processing', { + step: 'recognition', + }); + } + } + + // 识别完成 + recognitionComplete(result: any) { + if (this.taskId) { + this.analytics.trackTask(this.taskId, 'complete', { + step: 'complete', + has_result: !!result, + }); + } + } + + // 识别失败 + recognitionFailed(error: string) { + if (this.taskId) { + this.analytics.trackTask(this.taskId, 'failed', { + step: 'error', + error_message: error, + }); + } + } + + // 查看结果 + viewResult() { + if (this.taskId) { + this.analytics.trackTask(this.taskId, 'view_result', { + step: 'view', + }); + } + } + + // 导出结果 + exportResult(format: string) { + if (this.taskId) { + this.analytics.trackTask(this.taskId, 'export', { + step: 'export', + export_format: format, + }); + } + } +} + +// 12. 错误追踪 +window.addEventListener('error', (event) => { + getAnalytics().track({ + event_name: 'javascript_error', + properties: { + error_message: event.message, + error_filename: event.filename, + error_line: event.lineno, + error_column: event.colno, + }, + }); +}); + +// 13. 性能追踪 +window.addEventListener('load', () => { + const perfData = performance.timing; + const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart; + + getAnalytics().track({ + event_name: 'page_performance', + properties: { + page_load_time: pageLoadTime, + dns_time: perfData.domainLookupEnd - perfData.domainLookupStart, + tcp_time: perfData.connectEnd - perfData.connectStart, + request_time: perfData.responseEnd - perfData.requestStart, + dom_parse_time: perfData.domComplete - perfData.domLoading, + }, + }); +}); + +export {}; diff --git a/go.mod b/go.mod index 941bd6d..af65a6c 100644 --- a/go.mod +++ b/go.mod @@ -17,13 +17,15 @@ require ( github.com/spf13/viper v1.19.0 golang.org/x/crypto v0.23.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gorm.io/datatypes v1.2.7 gorm.io/driver/mysql v1.5.7 - gorm.io/gorm v1.25.12 + gorm.io/gorm v1.30.0 ) -require github.com/go-sql-driver/mysql v1.7.0 // indirect +require github.com/go-sql-driver/mysql v1.8.1 // indirect require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 // indirect github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 // indirect github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect @@ -74,7 +76,7 @@ require ( golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index f4041be..27d6d2e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 h1:iC9YFYKDGEy3n/FtqJnOkZsene9olVspKmkX5A2YBEo= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= github.com/alibabacloud-go/darabonba-openapi v0.1.18/go.mod h1:PB4HffMhJVmAgNKNq3wYbTUlFvPgxJpTzd1F5pTuUsc= @@ -74,11 +76,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -89,6 +96,14 @@ github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3 github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -118,6 +133,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -209,6 +228,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -221,8 +242,8 @@ golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -247,10 +268,18 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= +gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= +gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= -gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/model/analytics/request.go b/internal/model/analytics/request.go new file mode 100644 index 0000000..89f87e9 --- /dev/null +++ b/internal/model/analytics/request.go @@ -0,0 +1,33 @@ +package analytics + +import "time" + +// TrackEventRequest 埋点事件请求 +type TrackEventRequest struct { + UserID int64 `json:"user_id" binding:"required"` + EventName string `json:"event_name" binding:"required"` + Properties map[string]interface{} `json:"properties"` + DeviceInfo map[string]interface{} `json:"device_info"` + MetaData map[string]interface{} `json:"meta_data"` +} + +// BatchTrackEventRequest 批量埋点事件请求 +type BatchTrackEventRequest struct { + Events []TrackEventRequest `json:"events" binding:"required,min=1,max=100"` +} + +// QueryEventsRequest 查询事件请求 +type QueryEventsRequest struct { + UserID *int64 `json:"user_id" form:"user_id"` + EventName string `json:"event_name" form:"event_name"` + StartTime *time.Time `json:"start_time" form:"start_time"` + EndTime *time.Time `json:"end_time" form:"end_time"` + Page int `json:"page" form:"page" binding:"required,min=1"` + PageSize int `json:"page_size" form:"page_size" binding:"required,min=1,max=100"` +} + +// EventStatsRequest 事件统计请求 +type EventStatsRequest struct { + StartTime time.Time `json:"start_time" form:"start_time" binding:"required"` + EndTime time.Time `json:"end_time" form:"end_time" binding:"required"` +} diff --git a/internal/model/analytics/response.go b/internal/model/analytics/response.go new file mode 100644 index 0000000..eb2b72e --- /dev/null +++ b/internal/model/analytics/response.go @@ -0,0 +1,36 @@ +package analytics + +import "time" + +// EventResponse 事件响应 +type EventResponse struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + EventName string `json:"event_name"` + Properties map[string]interface{} `json:"properties"` + DeviceInfo map[string]interface{} `json:"device_info"` + MetaData map[string]interface{} `json:"meta_data"` + CreatedAt time.Time `json:"created_at"` +} + +// EventListResponse 事件列表响应 +type EventListResponse struct { + Events []*EventResponse `json:"events"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +// EventStatsResponse 事件统计响应 +type EventStatsResponse struct { + EventName string `json:"event_name"` + Count int64 `json:"count"` + UniqueUsers int64 `json:"unique_users"` +} + +// EventStatsListResponse 事件统计列表响应 +type EventStatsListResponse struct { + Stats []*EventStatsResponse `json:"stats"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go new file mode 100644 index 0000000..f061fe7 --- /dev/null +++ b/internal/service/analytics_service.go @@ -0,0 +1,234 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "gitea.com/texpixel/document_ai/internal/model/analytics" + "gitea.com/texpixel/document_ai/internal/storage/dao" + "gitea.com/texpixel/document_ai/pkg/log" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type AnalyticsService struct { + db *gorm.DB + eventDao *dao.AnalyticsEventDao +} + +func NewAnalyticsService() *AnalyticsService { + return &AnalyticsService{ + eventDao: dao.NewAnalyticsEventDao(), + } +} + +// TrackEvent 记录单个事件 +func (s *AnalyticsService) TrackEvent(ctx context.Context, req *analytics.TrackEventRequest) error { + // 将 map 转换为 JSON + propertiesJSON, err := json.Marshal(req.Properties) + if err != nil { + log.Error(ctx, "marshal properties failed", "error", err) + return fmt.Errorf("invalid properties format") + } + + deviceInfoJSON, err := json.Marshal(req.DeviceInfo) + if err != nil { + log.Error(ctx, "marshal device_info failed", "error", err) + return fmt.Errorf("invalid device_info format") + } + + metaDataJSON, err := json.Marshal(req.MetaData) + if err != nil { + log.Error(ctx, "marshal meta_data failed", "error", err) + return fmt.Errorf("invalid meta_data format") + } + + event := &dao.AnalyticsEvent{ + UserID: req.UserID, + EventName: req.EventName, + Properties: datatypes.JSON(propertiesJSON), + DeviceInfo: datatypes.JSON(deviceInfoJSON), + MetaData: datatypes.JSON(metaDataJSON), + CreatedAt: time.Now(), + } + + if err := s.eventDao.Create(s.db, event); err != nil { + log.Error(ctx, "create analytics event failed", "error", err) + return fmt.Errorf("failed to track event") + } + + log.Info(ctx, "event tracked successfully", + "event_id", event.ID, + "user_id", req.UserID, + "event_name", req.EventName) + + return nil +} + +// BatchTrackEvents 批量记录事件 +func (s *AnalyticsService) BatchTrackEvents(ctx context.Context, req *analytics.BatchTrackEventRequest) error { + events := make([]*dao.AnalyticsEvent, 0, len(req.Events)) + + for _, eventReq := range req.Events { + propertiesJSON, err := json.Marshal(eventReq.Properties) + if err != nil { + log.Error(ctx, "marshal properties failed", "error", err) + continue + } + + deviceInfoJSON, err := json.Marshal(eventReq.DeviceInfo) + if err != nil { + log.Error(ctx, "marshal device_info failed", "error", err) + continue + } + + metaDataJSON, err := json.Marshal(eventReq.MetaData) + if err != nil { + log.Error(ctx, "marshal meta_data failed", "error", err) + continue + } + + event := &dao.AnalyticsEvent{ + UserID: eventReq.UserID, + EventName: eventReq.EventName, + Properties: datatypes.JSON(propertiesJSON), + DeviceInfo: datatypes.JSON(deviceInfoJSON), + MetaData: datatypes.JSON(metaDataJSON), + CreatedAt: time.Now(), + } + events = append(events, event) + } + + if len(events) == 0 { + return fmt.Errorf("no valid events to track") + } + + if err := s.eventDao.BatchCreate(s.db, events); err != nil { + log.Error(ctx, "batch create analytics events failed", "error", err) + return fmt.Errorf("failed to batch track events") + } + + log.Info(ctx, "batch events tracked successfully", "count", len(events)) + return nil +} + +// QueryEvents 查询事件 +func (s *AnalyticsService) QueryEvents(ctx context.Context, req *analytics.QueryEventsRequest) (*analytics.EventListResponse, error) { + var events []*dao.AnalyticsEvent + var total int64 + var err error + + // 根据不同条件查询 + if req.UserID != nil && req.EventName != "" { + // 查询用户的指定事件 + events, total, err = s.eventDao.GetUserEventsByName(s.db, *req.UserID, req.EventName, req.Page, req.PageSize) + } else if req.UserID != nil { + // 查询用户的所有事件 + events, total, err = s.eventDao.GetUserEvents(s.db, *req.UserID, req.Page, req.PageSize) + } else if req.EventName != "" { + // 查询指定事件 + events, total, err = s.eventDao.GetEventsByName(s.db, req.EventName, req.Page, req.PageSize) + } else if req.StartTime != nil && req.EndTime != nil { + // 查询时间范围内的事件 + events, total, err = s.eventDao.GetEventsByTimeRange(s.db, *req.StartTime, *req.EndTime, req.Page, req.PageSize) + } else { + return nil, fmt.Errorf("invalid query parameters") + } + + if err != nil { + log.Error(ctx, "query events failed", "error", err) + return nil, fmt.Errorf("failed to query events") + } + + // 转换为响应格式 + eventResponses := make([]*analytics.EventResponse, 0, len(events)) + for _, event := range events { + var properties, deviceInfo, metaData map[string]interface{} + + if len(event.Properties) > 0 { + json.Unmarshal(event.Properties, &properties) + } + if len(event.DeviceInfo) > 0 { + json.Unmarshal(event.DeviceInfo, &deviceInfo) + } + if len(event.MetaData) > 0 { + json.Unmarshal(event.MetaData, &metaData) + } + + eventResponses = append(eventResponses, &analytics.EventResponse{ + ID: event.ID, + UserID: event.UserID, + EventName: event.EventName, + Properties: properties, + DeviceInfo: deviceInfo, + MetaData: metaData, + CreatedAt: event.CreatedAt, + }) + } + + return &analytics.EventListResponse{ + Events: eventResponses, + Total: total, + Page: req.Page, + Size: req.PageSize, + }, nil +} + +// GetEventStats 获取事件统计 +func (s *AnalyticsService) GetEventStats(ctx context.Context, req *analytics.EventStatsRequest) (*analytics.EventStatsListResponse, error) { + results, err := s.eventDao.GetEventStats(s.db, req.StartTime, req.EndTime) + if err != nil { + log.Error(ctx, "get event stats failed", "error", err) + return nil, fmt.Errorf("failed to get event stats") + } + + stats := make([]*analytics.EventStatsResponse, 0, len(results)) + for _, result := range results { + stats = append(stats, &analytics.EventStatsResponse{ + EventName: result["event_name"].(string), + Count: result["count"].(int64), + UniqueUsers: result["unique_users"].(int64), + }) + } + + return &analytics.EventStatsListResponse{ + Stats: stats, + StartTime: req.StartTime, + EndTime: req.EndTime, + }, nil +} + +// CountUserEvents 统计用户事件数量 +func (s *AnalyticsService) CountUserEvents(ctx context.Context, userID int64) (int64, error) { + count, err := s.eventDao.CountUserEvents(s.db, userID) + if err != nil { + log.Error(ctx, "count user events failed", "error", err, "user_id", userID) + return 0, fmt.Errorf("failed to count user events") + } + return count, nil +} + +// CountEventsByName 统计指定事件的数量 +func (s *AnalyticsService) CountEventsByName(ctx context.Context, eventName string) (int64, error) { + count, err := s.eventDao.CountEventsByName(s.db, eventName) + if err != nil { + log.Error(ctx, "count events by name failed", "error", err, "event_name", eventName) + return 0, fmt.Errorf("failed to count events") + } + return count, nil +} + +// CleanOldEvents 清理旧数据(可以定时执行) +func (s *AnalyticsService) CleanOldEvents(ctx context.Context, retentionDays int) error { + beforeTime := time.Now().AddDate(0, 0, -retentionDays) + + if err := s.eventDao.DeleteOldEvents(s.db, beforeTime); err != nil { + log.Error(ctx, "clean old events failed", "error", err, "before_time", beforeTime) + return fmt.Errorf("failed to clean old events") + } + + log.Info(ctx, "old events cleaned successfully", "retention_days", retentionDays) + return nil +} diff --git a/internal/storage/dao/analytics_event.go b/internal/storage/dao/analytics_event.go new file mode 100644 index 0000000..f4db890 --- /dev/null +++ b/internal/storage/dao/analytics_event.go @@ -0,0 +1,170 @@ +package dao + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// AnalyticsEvent 数据埋点事件表 +type AnalyticsEvent struct { + ID int64 `gorm:"bigint;primaryKey;autoIncrement;column:id;comment:主键ID" json:"id"` + UserID int64 `gorm:"column:user_id;not null;index:idx_user_id;comment:用户ID" json:"user_id"` + EventName string `gorm:"column:event_name;varchar(128);not null;index:idx_event_name;comment:事件名称" json:"event_name"` + Properties datatypes.JSON `gorm:"column:properties;type:json;comment:事件属性(JSON)" json:"properties"` + DeviceInfo datatypes.JSON `gorm:"column:device_info;type:json;comment:设备信息(JSON)" json:"device_info"` + MetaData datatypes.JSON `gorm:"column:meta_data;type:json;comment:元数据(JSON,包含task_id等)" json:"meta_data"` + CreatedAt time.Time `gorm:"column:created_at;comment:创建时间;not null;default:current_timestamp;index:idx_created_at" json:"created_at"` +} + +func (e *AnalyticsEvent) TableName() string { + return "analytics_events" +} + +// AnalyticsEventDao 数据埋点事件DAO +type AnalyticsEventDao struct{} + +func NewAnalyticsEventDao() *AnalyticsEventDao { + return &AnalyticsEventDao{} +} + +// Create 创建事件记录 +func (dao *AnalyticsEventDao) Create(tx *gorm.DB, event *AnalyticsEvent) error { + return tx.Create(event).Error +} + +// BatchCreate 批量创建事件记录 +func (dao *AnalyticsEventDao) BatchCreate(tx *gorm.DB, events []*AnalyticsEvent) error { + if len(events) == 0 { + return nil + } + return tx.CreateInBatches(events, 100).Error +} + +// GetByID 根据ID获取事件 +func (dao *AnalyticsEventDao) GetByID(tx *gorm.DB, id int64) (*AnalyticsEvent, error) { + event := &AnalyticsEvent{} + err := tx.Where("id = ?", id).First(event).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return event, nil +} + +// GetUserEvents 获取用户的事件列表 +func (dao *AnalyticsEventDao) GetUserEvents(tx *gorm.DB, userID int64, page, pageSize int) ([]*AnalyticsEvent, int64, error) { + var events []*AnalyticsEvent + var total int64 + + offset := (page - 1) * pageSize + query := tx.Model(&AnalyticsEvent{}).Where("user_id = ?", userID) + + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = query.Offset(offset).Limit(pageSize). + Order(clause.OrderByColumn{Column: clause.Column{Name: "created_at"}, Desc: true}). + Find(&events).Error + + return events, total, err +} + +// GetEventsByName 根据事件名称获取事件列表 +func (dao *AnalyticsEventDao) GetEventsByName(tx *gorm.DB, eventName string, page, pageSize int) ([]*AnalyticsEvent, int64, error) { + var events []*AnalyticsEvent + var total int64 + + offset := (page - 1) * pageSize + query := tx.Model(&AnalyticsEvent{}).Where("event_name = ?", eventName) + + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = query.Offset(offset).Limit(pageSize). + Order(clause.OrderByColumn{Column: clause.Column{Name: "created_at"}, Desc: true}). + Find(&events).Error + + return events, total, err +} + +// GetUserEventsByName 获取用户指定事件的列表 +func (dao *AnalyticsEventDao) GetUserEventsByName(tx *gorm.DB, userID int64, eventName string, page, pageSize int) ([]*AnalyticsEvent, int64, error) { + var events []*AnalyticsEvent + var total int64 + + offset := (page - 1) * pageSize + query := tx.Model(&AnalyticsEvent{}).Where("user_id = ? AND event_name = ?", userID, eventName) + + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = query.Offset(offset).Limit(pageSize). + Order(clause.OrderByColumn{Column: clause.Column{Name: "created_at"}, Desc: true}). + Find(&events).Error + + return events, total, err +} + +// GetEventsByTimeRange 根据时间范围获取事件列表 +func (dao *AnalyticsEventDao) GetEventsByTimeRange(tx *gorm.DB, startTime, endTime time.Time, page, pageSize int) ([]*AnalyticsEvent, int64, error) { + var events []*AnalyticsEvent + var total int64 + + offset := (page - 1) * pageSize + query := tx.Model(&AnalyticsEvent{}).Where("created_at BETWEEN ? AND ?", startTime, endTime) + + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = query.Offset(offset).Limit(pageSize). + Order(clause.OrderByColumn{Column: clause.Column{Name: "created_at"}, Desc: true}). + Find(&events).Error + + return events, total, err +} + +// CountEventsByName 统计指定事件的数量 +func (dao *AnalyticsEventDao) CountEventsByName(tx *gorm.DB, eventName string) (int64, error) { + var count int64 + err := tx.Model(&AnalyticsEvent{}).Where("event_name = ?", eventName).Count(&count).Error + return count, err +} + +// CountUserEvents 统计用户的事件数量 +func (dao *AnalyticsEventDao) CountUserEvents(tx *gorm.DB, userID int64) (int64, error) { + var count int64 + err := tx.Model(&AnalyticsEvent{}).Where("user_id = ?", userID).Count(&count).Error + return count, err +} + +// GetEventStats 获取事件统计信息(按事件名称分组) +func (dao *AnalyticsEventDao) GetEventStats(tx *gorm.DB, startTime, endTime time.Time) ([]map[string]interface{}, error) { + var results []map[string]interface{} + + err := tx.Model(&AnalyticsEvent{}). + Select("event_name, COUNT(*) as count, COUNT(DISTINCT user_id) as unique_users"). + Where("created_at BETWEEN ? AND ?", startTime, endTime). + Group("event_name"). + Order("count DESC"). + Find(&results).Error + + return results, err +} + +// DeleteOldEvents 删除旧事件(数据清理) +func (dao *AnalyticsEventDao) DeleteOldEvents(tx *gorm.DB, beforeTime time.Time) error { + return tx.Where("created_at < ?", beforeTime).Delete(&AnalyticsEvent{}).Error +} diff --git a/main.go b/main.go index bdc9ae8..d249d6b 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,7 @@ func main() { // 使用中间件 r.Use(gin.Recovery(), middleware.RequestID(), middleware.AccessLog(), cors.Cors(cors.DefaultConfig()), common.MiddlewareContext) router := r.Group("doc_ai") - api.SetupRouter(router) + api.SetupRouter(router, dao.DB) // 启动服务器 addr := fmt.Sprintf(":%d", config.GlobalConfig.Server.Port) diff --git a/migrations/analytics_events.sql b/migrations/analytics_events.sql new file mode 100644 index 0000000..b818ff8 --- /dev/null +++ b/migrations/analytics_events.sql @@ -0,0 +1,18 @@ +-- 数据埋点事件表 +CREATE TABLE IF NOT EXISTS `analytics_events` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `event_name` VARCHAR(128) NOT NULL COMMENT '事件名称', + `properties` JSON DEFAULT NULL COMMENT '事件属性(JSON)', + `device_info` JSON DEFAULT NULL COMMENT '设备信息(JSON)', + `meta_data` JSON DEFAULT NULL COMMENT '元数据(JSON,包含task_id等)', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_event_name` (`event_name`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据埋点事件表'; + +-- 创建复合索引以提高查询性能 +CREATE INDEX `idx_user_event` ON `analytics_events` (`user_id`, `event_name`); +CREATE INDEX `idx_event_time` ON `analytics_events` (`event_name`, `created_at`);