feat(database): 实现数据库功能并优化数据导出

- 新增数据库相关 API 和服务
- 实现数据导出功能,支持导出到 JSON 文件
- 优化数据导入流程,增加数据校验
- 新增数据库页面,展示解析数据和统计信息
- 更新捕获页面,支持导入数据到数据库
This commit is contained in:
hu xiaotong
2025-07-04 12:48:40 +08:00
parent 910e2d4c4d
commit 1b90af57ba
20 changed files with 973 additions and 356312 deletions

View File

@@ -7,7 +7,11 @@
- 🎯 **TCP包抓取** - 实时抓取游戏客户端的TCP通信数据
- 🔍 **数据解析** - 自动解析十六进制数据为可读的装备信息
- 📊 **数据可视化** - 现代化的React界面展示装备数据
- 💾 **数据导出** - 支持导出为JSON格式
- 💾 **数据持久化** - 基于SQLite的本地数据存储支持装备数据的增删改查
- 📤 **数据导出** - 支持导出为JSON格式
- 🔍 **数据搜索** - 支持装备数据的模糊搜索和筛选
- 📈 **数据统计** - 提供装备数据的统计分析功能
- ⚙️ **设置管理** - 应用设置的本地持久化存储
- 🚀 **高性能** - 基于Go语言的高性能网络处理
## 技术栈
@@ -17,6 +21,7 @@
- **Wails v2** - 桌面应用框架
- **gopacket** - 网络包抓取
- **zap** - 结构化日志
- **SQLite** - 本地数据存储
### 前端
- **React 18** - 用户界面框架
@@ -109,6 +114,56 @@ equipment-analyzer/
## 开发指南
### 常见问题解决
#### 1. React严格模式导致的重复调用
在开发模式下React严格模式会导致组件渲染两次这可能导致
- useEffect被重复执行
- 异步操作被重复调用
- 用户提示消息重复显示
**解决方案:**
- **全局消息去重配置**:在`frontend/src/utils/messageConfig.ts`中配置Antd message的全局设置
- **防重复机制**使用缓存机制防止2秒内相同消息重复显示
- **最大显示数量限制**:设置`maxCount: 1`确保同时只显示一个消息
**全局配置:**
```typescript
// frontend/src/utils/messageConfig.ts
import { message } from 'antd';
// 配置全局消息设置,防止重复显示
message.config({
maxCount: 1, // 最多同时显示1个消息
duration: 3, // 消息显示3秒
top: 24, // 距离顶部24px
});
// 防重复消息函数
const messageCache = new Map<string, number>();
const DEBOUNCE_TIME = 2000; // 2秒内相同消息不重复显示
export const showMessage = (type: 'success' | 'error' | 'warning' | 'info', content: string) => {
const messageId = `${type}:${content}`;
const now = Date.now();
// 检查是否在防抖时间内
const lastTime = messageCache.get(messageId);
if (lastTime && now - lastTime < DEBOUNCE_TIME) {
return; // 跳过重复消息
}
// 更新缓存并显示消息
messageCache.set(messageId, now);
message[type](content);
};
```
**使用方法:**
-`main.tsx`中导入配置:`import './utils/messageConfig'`
- 在组件中正常使用`message.success()``message.error()`
- 全局配置会自动防止重复消息显示
### 添加新功能
1. **后端功能**
@@ -121,6 +176,23 @@ equipment-analyzer/
- 使用TypeScript确保类型安全
- 遵循Ant Design设计规范
### 数据库操作
1. **添加新的数据表**
-`internal/model/database.go``initTables()`方法中添加表结构
-`Database`结构体中添加相应的CRUD方法
-`DatabaseService`中添加业务逻辑方法
-`App`中添加API接口方法
2. **数据迁移**
- 数据库结构变更时,在`initTables()`方法中处理版本升级
- 使用事务确保数据一致性
3. **性能优化**
- 为常用查询字段添加索引
- 使用批量操作减少数据库交互
- 合理使用连接池和事务
### 测试
```bash
@@ -147,8 +219,27 @@ make release
make clean
```
## 配置说明
## 数据存储
### 数据库存储
应用使用SQLite数据库进行本地数据持久化数据库文件位置`~/.equipment-analyzer/equipment_analyzer.db`
#### 数据库表结构
- **equipment** - 装备基本信息表
- **equipment_operations** - 装备操作属性表
- **capture_sessions** - 抓包会话记录表
- **raw_packet_data** - 原始数据包表
- **app_settings** - 应用设置表
#### 数据库功能
- ✅ 装备数据的增删改查
- ✅ 批量数据导入导出
- ✅ 装备数据搜索和筛选
- ✅ 数据统计分析
- ✅ 应用设置持久化
- ✅ 抓包会话历史记录
### 配置文件
应用会在用户目录下创建配置文件:`~/.equipment-analyzer/config.json`
```json

View File

@@ -1,14 +1,10 @@
import React, {useEffect, useState, useRef} from 'react'
import {Button, Card, Layout, message, Select, Spin, Table} from 'antd'
import {DownloadOutlined, PlayCircleOutlined, SettingOutlined, StopOutlined} from '@ant-design/icons'
import React from 'react'
import {Layout, Menu} from 'antd'
import './App.css'
import {ExportData, GetCapturedData,GetNetworkInterfaces,
ParseData,StartCapture,StopCapture, ReadRawJsonFile
} from "../wailsjs/go/service/App";
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import { Menu } from 'antd';
import {BrowserRouter as Router, Link, Route, Routes, useLocation} from 'react-router-dom';
import CapturePage from './pages/CapturePage';
import OptimizerPage from './pages/OptimizerPage';
import DatabasePage from './pages/DatabasePage';
const {Header, Content, Sider} = Layout
@@ -51,6 +47,9 @@ function AppContent() {
<Menu.Item key="/">
<Link to="/"></Link>
</Menu.Item>
<Menu.Item key="/database">
<Link to="/database"></Link>
</Menu.Item>
<Menu.Item key="/optimizer">
<Link to="/optimizer"></Link>
</Menu.Item>
@@ -59,6 +58,7 @@ function AppContent() {
<Content style={{ padding: 0, minHeight: 280 }}>
<Routes>
<Route path="/" element={<CapturePage />} />
<Route path="/database" element={<DatabasePage />} />
<Route path="/optimizer" element={<OptimizerPage />} />
</Routes>
</Content>

View File

@@ -1,11 +1,11 @@
import React, {useEffect, useRef, useState} from 'react';
import {Button, Card, Layout, message, Select, Spin, Table} from 'antd';
import {DownloadOutlined, PlayCircleOutlined, SettingOutlined, StopOutlined} from '@ant-design/icons';
import {DownloadOutlined, PlayCircleOutlined, SettingOutlined, StopOutlined, UploadOutlined} from '@ant-design/icons';
import '../App.css';
import {
ExportData,
GetNetworkInterfaces,
ReadRawJsonFile,
SaveParsedDataToDatabase,
StartCapture,
StopAndParseCapture
} from '../../wailsjs/go/service/App';
@@ -170,50 +170,102 @@ function CapturePage() {
}
};
const exportData = async () => {
if (!capturedData.length) {
const exportData = () => {
// 检查是否有解析数据
if (!parsedData || !Array.isArray(parsedData.items) || parsedData.items.length === 0) {
showMessage('warning', '没有数据可导出');
return;
}
try {
const filename = `equipment_data_${Date.now()}.json`;
const result = await safeApiCall(
() => ExportData(capturedData, filename),
'导出数据失败'
);
if (result === undefined) {
showMessage('error', '导出数据失败');
return;
}
// 创建要导出的数据内容
const exportContent = JSON.stringify(parsedData, null, 2);
// 创建 Blob 对象
const blob = new Blob([exportContent], { type: 'text/plain;charset=utf-8' });
// 创建下载链接
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'gear.txt';
// 触发下载
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(url);
showMessage('success', '数据导出成功');
} catch (error) {
console.error('导出数据时发生未知错误:', error);
console.error('导出数据时发生错误:', error);
showMessage('error', '导出数据失败');
}
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploadedFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
reader.onload = async (e) => {
try {
const text = e.target?.result as string;
const json = JSON.parse(text);
// 校验数据格式
if (!json || typeof json !== 'object') {
showMessage('error', '文件格式错误不是有效的JSON对象');
return;
}
if (!json.heroes || !Array.isArray(json.heroes)) {
showMessage('error', '数据格式错误缺少heroes数组');
return;
}
if (!json.items || !Array.isArray(json.items)) {
showMessage('error', '数据格式错误缺少items数组');
return;
}
if (json.heroes.length === 0) {
showMessage('error', '数据格式错误heroes数组不能为空');
return;
}
if (json.items.length === 0) {
showMessage('error', '数据格式错误items数组不能为空');
return;
}
// 数据校验通过,保存到数据库
const sessionName = `import_${Date.now()}`;
const equipmentJSON = JSON.stringify(json.items);
const heroesJSON = JSON.stringify(json.heroes);
await SaveParsedDataToDatabase(sessionName, equipmentJSON, heroesJSON);
// 更新界面显示
const safeData = {
items: Array.isArray(json.items) ? json.items : [],
heroes: Array.isArray(json.heroes) ? json.heroes : []
items: json.items,
heroes: json.heroes
};
setParsedData(safeData);
if (safeData.items.length === 0 && safeData.heroes.length === 0) {
showMessage('warning', '上传文件数据为空,请检查文件内容');
} else {
showMessage('success', `文件解析成功:${safeData.items.length}件装备,${safeData.heroes.length}个英雄`);
}
setUploadedFileName(''); // 清空文件名显示
showMessage('success', `数据导入成功:${json.items.length}件装备,${json.heroes.length}个英雄,已保存到数据库`);
} catch (err) {
console.error('文件格式错误,无法解析:', err);
showMessage('error', '文件格式错误,无法解析');
console.error('文件处理错误:', err);
if (err instanceof SyntaxError) {
showMessage('error', '文件格式错误不是有效的JSON格式');
} else {
showMessage('error', '数据导入失败');
}
setParsedData({ items: [], heroes: [] });
}
};
@@ -315,19 +367,6 @@ function CapturePage() {
loading={loading}
style={{ flex: 1, height: 32 }}
></Button>
<Button style={{ flex: 1, height: 32 }} onClick={handleUploadButtonClick}>
JSON
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json"
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
{uploadedFileName && (
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{uploadedFileName}</span>
)}
</div>
</div>
</Header>
@@ -379,10 +418,28 @@ function CapturePage() {
<Button
icon={<DownloadOutlined />}
onClick={exportData}
disabled={!capturedData.length}
disabled={!parsedData || !Array.isArray(parsedData.items) || parsedData.items.length === 0}
style={{ width: '100%', height: 32, fontSize: 14 }}>
</Button>
<Button
icon={<UploadOutlined />}
onClick={handleUploadButtonClick}
style={{ width: '100%', height: 32, fontSize: 14 }}>
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt"
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
{uploadedFileName && (
<span style={{ marginTop: 4, color: '#888', fontSize: 10, textAlign: 'center', display: 'block' }}>
{uploadedFileName}
</span>
)}
</div>
</Card>

View File

@@ -0,0 +1,203 @@
import React, {useEffect, useState} from 'react';
import {Button, Card, Col, Row, Space, Statistic, Table, Tag,} from 'antd';
import {BarChartOutlined, DatabaseOutlined, ReloadOutlined, SettingOutlined,} from '@ant-design/icons';
import * as App from '../../wailsjs/go/service/App';
import {model} from '../../wailsjs/go/models';
import {useMessage} from '../utils/useMessage';
// 定义Equipment接口
interface Equipment {
id: string | number;
code: string;
ct: number;
e: number;
g: number;
l: boolean;
mg: number;
op: any[];
p: number;
s: string;
sk: number;
}
const DatabasePage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [latestData, setLatestData] = useState<model.ParsedResult | null>(null);
const { success, error, info } = useMessage();
// 加载最新解析数据
const loadLatestData = async () => {
// 防止重复调用
if (loading) return;
setLoading(true);
try {
const parsedResult = await App.GetLatestParsedDataFromDatabase();
if (parsedResult && (parsedResult.items?.length > 0 || parsedResult.heroes?.length > 0)) {
setLatestData(parsedResult);
success('数据加载成功');
} else {
setLatestData(null);
info('暂无解析数据');
}
} catch (err) {
error('加载数据失败');
console.error('Load data error:', err);
setLatestData(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLatestData();
}, []);
// 装备表格列定义
const equipmentColumns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
render: (id: any) => {
const idStr = String(id || '');
return <span style={{ fontSize: '12px' }}>{idStr.length > 8 ? `${idStr.slice(0, 8)}...` : idStr}</span>;
},
},
{
title: '代码',
dataIndex: 'code',
key: 'code',
width: 120,
},
{
title: '等级',
dataIndex: 'g',
key: 'g',
width: 80,
render: (grade: number) => (
<Tag color={grade >= 5 ? 'red' : grade >= 3 ? 'orange' : 'green'}>
{grade}
</Tag>
),
},
{
title: '经验',
dataIndex: 'e',
key: 'e',
width: 100,
render: (exp: number) => exp.toLocaleString(),
},
{
title: '力量',
dataIndex: 'p',
key: 'p',
width: 80,
},
{
title: '魔法',
dataIndex: 'mg',
key: 'mg',
width: 80,
},
{
title: '技能',
dataIndex: 'sk',
key: 'sk',
width: 80,
},
{
title: '状态',
dataIndex: 'l',
key: 'l',
width: 80,
render: (locked: boolean) => (
<Tag color={locked ? 'red' : 'green'}>
{locked ? '锁定' : '正常'}
</Tag>
),
},
];
return (
<div style={{ padding: '24px' }}>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={8}>
<Card>
<Statistic
title="数据库状态"
value="SQLite"
prefix={<DatabaseOutlined />}
suffix="已连接"
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="装备数量"
value={latestData?.items?.length || 0}
prefix={<BarChartOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="英雄数量"
value={latestData?.heroes?.length || 0}
prefix={<SettingOutlined />}
/>
</Card>
</Col>
</Row>
{/* 操作栏 */}
<Card style={{ marginBottom: '16px' }}>
<Space>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={loadLatestData}
loading={loading}
>
</Button>
<span style={{ color: '#666' }}>
</span>
</Space>
</Card>
{/* 装备表格 */}
<Card title="最新解析的装备数据">
{latestData?.items && latestData.items.length > 0 ? (
<Table
columns={equipmentColumns}
dataSource={latestData.items}
rowKey="id"
loading={loading}
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total}`,
}}
scroll={{ x: 800 }}
/>
) : (
<div style={{ textAlign: 'center', padding: '40px' }}>
<p></p>
<p style={{ color: '#999', fontSize: '12px' }}>
</p>
</div>
)}
</Card>
</div>
);
};
export default DatabasePage;

View File

@@ -0,0 +1,66 @@
import {message} from 'antd';
import {useRef} from 'react';
// 全局消息缓存,用于防止重复显示
const messageCache = new Map<string, number>();
const DEBOUNCE_TIME = 2000; // 2秒内相同消息不重复显示
export const useMessage = () => {
const messageKeyRef = useRef<string>('');
const showMessage = (type: 'success' | 'error' | 'warning' | 'info', content: string, duration?: number) => {
// 生成消息的唯一标识
const messageId = `${type}:${content}`;
const now = Date.now();
// 检查是否在防抖时间内
const lastTime = messageCache.get(messageId);
if (lastTime && now - lastTime < DEBOUNCE_TIME) {
return; // 跳过重复消息
}
// 更新缓存
messageCache.set(messageId, now);
// 清理过期的缓存项超过10秒的
const expiredKeys: string[] = [];
messageCache.forEach((timestamp, msgId) => {
if (now - timestamp > 10000) {
expiredKeys.push(msgId);
}
});
expiredKeys.forEach(key => messageCache.delete(key));
// 显示消息
message[type](content, duration);
};
const success = (content: string, duration?: number) => {
showMessage('success', content, duration);
};
const error = (content: string, duration?: number) => {
showMessage('error', content, duration);
};
const warning = (content: string, duration?: number) => {
showMessage('warning', content, duration);
};
const info = (content: string, duration?: number) => {
showMessage('info', content, duration);
};
const destroy = () => {
message.destroy();
};
return {
success,
error,
warning,
info,
destroy,
showMessage,
};
};

View File

@@ -2,18 +2,32 @@
// This file is automatically generated. DO NOT EDIT
import {model} from '../models';
export function ExportCurrentData(arg1:string):Promise<void>;
export function ExportData(arg1:Array<string>,arg2:string):Promise<void>;
export function GetAllAppSettings():Promise<Record<string, string>>;
export function GetAppSetting(arg1:string):Promise<string>;
export function GetCaptureStatus():Promise<model.CaptureStatus>;
export function GetCapturedData():Promise<Array<string>>;
export function GetCurrentDataForExport():Promise<string>;
export function GetLatestParsedDataFromDatabase():Promise<model.ParsedResult>;
export function GetNetworkInterfaces():Promise<Array<model.NetworkInterface>>;
export function ParseData(arg1:Array<string>):Promise<string>;
export function ReadRawJsonFile():Promise<model.ParsedResult>;
export function SaveAppSetting(arg1:string,arg2:string):Promise<void>;
export function SaveParsedDataToDatabase(arg1:string,arg2:string,arg3:string):Promise<void>;
export function StartCapture(arg1:string):Promise<void>;
export function StopAndParseCapture():Promise<model.ParsedResult>;

View File

@@ -2,10 +2,22 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function ExportCurrentData(arg1) {
return window['go']['service']['App']['ExportCurrentData'](arg1);
}
export function ExportData(arg1, arg2) {
return window['go']['service']['App']['ExportData'](arg1, arg2);
}
export function GetAllAppSettings() {
return window['go']['service']['App']['GetAllAppSettings']();
}
export function GetAppSetting(arg1) {
return window['go']['service']['App']['GetAppSetting'](arg1);
}
export function GetCaptureStatus() {
return window['go']['service']['App']['GetCaptureStatus']();
}
@@ -14,6 +26,14 @@ export function GetCapturedData() {
return window['go']['service']['App']['GetCapturedData']();
}
export function GetCurrentDataForExport() {
return window['go']['service']['App']['GetCurrentDataForExport']();
}
export function GetLatestParsedDataFromDatabase() {
return window['go']['service']['App']['GetLatestParsedDataFromDatabase']();
}
export function GetNetworkInterfaces() {
return window['go']['service']['App']['GetNetworkInterfaces']();
}
@@ -26,6 +46,14 @@ export function ReadRawJsonFile() {
return window['go']['service']['App']['ReadRawJsonFile']();
}
export function SaveAppSetting(arg1, arg2) {
return window['go']['service']['App']['SaveAppSetting'](arg1, arg2);
}
export function SaveParsedDataToDatabase(arg1, arg2, arg3) {
return window['go']['service']['App']['SaveParsedDataToDatabase'](arg1, arg2, arg3);
}
export function StartCapture(arg1) {
return window['go']['service']['App']['StartCapture'](arg1);
}

1
go.mod
View File

@@ -6,6 +6,7 @@ toolchain go1.24.4
require (
github.com/google/gopacket v1.1.19
github.com/mattn/go-sqlite3 v1.14.17
github.com/wailsapp/wails/v2 v2.10.1
go.uber.org/zap v1.26.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1

2
go.sum
View File

@@ -34,6 +34,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=

View File

@@ -23,7 +23,7 @@ func GetNetworkInterfaces() ([]model.NetworkInterface, error) {
continue
}
log.Printf("网卡名称: %s, 描述: %s", device.Name, device.Description) // 打印每个网卡的名称和描述
//log.Printf("网卡名称: %s, 描述: %s", device.Name, device.Description) // 打印每个网卡的名称和描述
// 提取IP地址
var addresses []string

163
internal/model/database.go Normal file
View File

@@ -0,0 +1,163 @@
package model
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Database 数据库管理器
type Database struct {
db *sql.DB
}
// NewDatabase 创建新的数据库连接
func NewDatabase() (*Database, error) {
dbPath := getDatabasePath()
// 确保目录存在
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
// 连接数据库
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
// 测试连接
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %w", err)
}
database := &Database{db: db}
// 初始化表结构
if err := database.initTables(); err != nil {
return nil, fmt.Errorf("初始化数据库表失败: %w", err)
}
return database, nil
}
// Close 关闭数据库连接
func (d *Database) Close() error {
return d.db.Close()
}
// getDatabasePath 获取数据库文件路径
func getDatabasePath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return "equipment_analyzer.db"
}
return filepath.Join(homeDir, ".equipment-analyzer", "equipment_analyzer.db")
}
// initTables 初始化数据库表结构
func (d *Database) initTables() error {
// 解析数据表 - 存储抓包解析后的装备和角色数据
parsedDataTable := `
CREATE TABLE IF NOT EXISTS parsed_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
items_json TEXT NOT NULL,
heroes_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);`
// 应用设置表
settingsTable := `
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);`
tables := []string{
parsedDataTable,
settingsTable,
}
for _, table := range tables {
if _, err := d.db.Exec(table); err != nil {
return fmt.Errorf("创建表失败: %w", err)
}
}
return nil
}
// SaveParsedData 保存解析后的数据
func (d *Database) SaveParsedData(sessionName string, itemsJSON, heroesJSON string) error {
stmt := `
INSERT INTO parsed_data (session_name, items_json, heroes_json, created_at)
VALUES (?, ?, ?, ?)`
_, err := d.db.Exec(stmt, sessionName, itemsJSON, heroesJSON, time.Now().Unix())
return err
}
// GetLatestParsedData 获取最新的解析数据
func (d *Database) GetLatestParsedData() (string, string, error) {
stmt := `
SELECT items_json, heroes_json
FROM parsed_data
ORDER BY created_at DESC
LIMIT 1`
var itemsJSON, heroesJSON string
err := d.db.QueryRow(stmt).Scan(&itemsJSON, &heroesJSON)
if err != nil {
return "", "", err
}
return itemsJSON, heroesJSON, nil
}
// SaveSetting 保存应用设置
func (d *Database) SaveSetting(key, value string) error {
stmt := "INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)"
_, err := d.db.Exec(stmt, key, value, time.Now().Unix())
return err
}
// GetSetting 获取应用设置
func (d *Database) GetSetting(key string) (string, error) {
stmt := "SELECT value FROM app_settings WHERE key = ?"
var value string
err := d.db.QueryRow(stmt, key).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
// GetAllSettings 获取所有设置
func (d *Database) GetAllSettings() (map[string]string, error) {
stmt := "SELECT key, value FROM app_settings"
rows, err := d.db.Query(stmt)
if err != nil {
return nil, err
}
defer rows.Close()
settings := make(map[string]string)
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return nil, err
}
settings[key] = value
}
return settings, nil
}

View File

@@ -0,0 +1,82 @@
package service
import (
"fmt"
"equipment-analyzer/internal/model"
"equipment-analyzer/internal/utils"
)
// DatabaseService 数据库服务
type DatabaseService struct {
db *model.Database
logger *utils.Logger
}
// NewDatabaseService 创建数据库服务
func NewDatabaseService(db *model.Database, logger *utils.Logger) *DatabaseService {
return &DatabaseService{
db: db,
logger: logger,
}
}
// SaveParsedDataToDatabase 保存解析后的数据到数据库
func (s *DatabaseService) SaveParsedDataToDatabase(sessionName string, itemsJSON, heroesJSON string) error {
err := s.db.SaveParsedData(sessionName, itemsJSON, heroesJSON)
if err != nil {
s.logger.Error("保存解析数据到数据库失败", "error", err, "session_name", sessionName)
return fmt.Errorf("保存解析数据失败: %w", err)
}
s.logger.Info("解析数据保存成功", "session_name", sessionName)
return nil
}
// GetLatestParsedDataFromDatabase 从数据库获取最新的解析数据
func (s *DatabaseService) GetLatestParsedDataFromDatabase() (string, string, error) {
itemsJSON, heroesJSON, err := s.db.GetLatestParsedData()
if err != nil {
s.logger.Error("从数据库获取最新解析数据失败", "error", err)
return "", "", fmt.Errorf("获取解析数据失败: %w", err)
}
s.logger.Info("最新解析数据获取成功")
return itemsJSON, heroesJSON, nil
}
// SaveAppSetting 保存应用设置
func (s *DatabaseService) SaveAppSetting(key, value string) error {
err := s.db.SaveSetting(key, value)
if err != nil {
s.logger.Error("保存应用设置失败", "error", err, "key", key)
return fmt.Errorf("保存设置失败: %w", err)
}
s.logger.Info("应用设置保存成功", "key", key)
return nil
}
// GetAppSetting 获取应用设置
func (s *DatabaseService) GetAppSetting(key string) (string, error) {
value, err := s.db.GetSetting(key)
if err != nil {
s.logger.Error("获取应用设置失败", "error", err, "key", key)
return "", fmt.Errorf("获取设置失败: %w", err)
}
return value, nil
}
// GetAllAppSettings 获取所有应用设置
func (s *DatabaseService) GetAllAppSettings() (map[string]string, error) {
settings, err := s.db.GetAllSettings()
if err != nil {
s.logger.Error("获取所有应用设置失败", "error", err)
return nil, fmt.Errorf("获取设置失败: %w", err)
}
return settings, nil
}

View File

@@ -2,8 +2,8 @@ package service
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"time"
"equipment-analyzer/internal/capture"
@@ -17,9 +17,16 @@ type App struct {
logger *utils.Logger
captureService *CaptureService
parserService *ParserService
database *model.Database
databaseService *DatabaseService
}
func NewApp(cfg *config.Config, logger *utils.Logger) *App {
// 初始化数据库
database, err := model.NewDatabase()
if err != nil {
logger.Error("初始化数据库失败", "error", err)
// 如果数据库初始化失败,仍然创建应用,但数据库功能不可用
return &App{
config: cfg,
logger: logger,
@@ -28,6 +35,18 @@ func NewApp(cfg *config.Config, logger *utils.Logger) *App {
}
}
databaseService := NewDatabaseService(database, logger)
return &App{
config: cfg,
logger: logger,
captureService: NewCaptureService(cfg, logger),
parserService: NewParserService(cfg, logger),
database: database,
databaseService: databaseService,
}
}
func (a *App) Startup(ctx context.Context) {
a.logger.Info("应用启动")
}
@@ -43,6 +62,15 @@ func (a *App) BeforeClose(ctx context.Context) (prevent bool) {
func (a *App) Shutdown(ctx context.Context) {
a.logger.Info("应用关闭")
// 关闭数据库连接
if a.database != nil {
if err := a.database.Close(); err != nil {
a.logger.Error("关闭数据库连接失败", "error", err)
} else {
a.logger.Info("数据库连接已关闭")
}
}
}
// GetNetworkInterfaces 获取网络接口列表
@@ -130,6 +158,80 @@ func (a *App) ExportData(hexDataList []string, filename string) error {
return nil
}
// ExportCurrentData 导出当前数据库中的数据到文件
func (a *App) ExportCurrentData(filename string) error {
if a.databaseService == nil {
return fmt.Errorf("数据库服务未初始化")
}
// 从数据库获取最新数据
parsedResult, err := a.GetLatestParsedDataFromDatabase()
if err != nil {
a.logger.Error("获取数据库数据失败", "error", err)
return err
}
if parsedResult == nil || (len(parsedResult.Items) == 0 && len(parsedResult.Heroes) == 0) {
return fmt.Errorf("没有数据可导出")
}
// 创建导出数据格式
exportData := map[string]interface{}{
"items": parsedResult.Items,
"heroes": parsedResult.Heroes,
}
// 序列化为JSON
jsonData, err := json.MarshalIndent(exportData, "", " ")
if err != nil {
a.logger.Error("序列化数据失败", "error", err)
return err
}
// 写入文件
err = utils.WriteFile(filename, jsonData)
if err != nil {
a.logger.Error("写入文件失败", "error", err)
return err
}
a.logger.Info("数据导出成功", "filename", filename, "items_count", len(parsedResult.Items), "heroes_count", len(parsedResult.Heroes))
return nil
}
// GetCurrentDataForExport 获取当前数据库中的数据,供前端导出使用
func (a *App) GetCurrentDataForExport() (string, error) {
if a.databaseService == nil {
return "", fmt.Errorf("数据库服务未初始化")
}
// 从数据库获取最新数据
parsedResult, err := a.GetLatestParsedDataFromDatabase()
if err != nil {
a.logger.Error("获取数据库数据失败", "error", err)
return "", err
}
if parsedResult == nil || (len(parsedResult.Items) == 0 && len(parsedResult.Heroes) == 0) {
return "", fmt.Errorf("没有数据可导出")
}
// 创建导出数据格式
exportData := map[string]interface{}{
"items": parsedResult.Items,
"heroes": parsedResult.Heroes,
}
// 序列化为JSON
jsonData, err := json.MarshalIndent(exportData, "", " ")
if err != nil {
a.logger.Error("序列化数据失败", "error", err)
return "", err
}
return string(jsonData), nil
}
// GetCaptureStatus 获取抓包状态
func (a *App) GetCaptureStatus() model.CaptureStatus {
return model.CaptureStatus{
@@ -145,13 +247,9 @@ func (a *App) getStatusMessage() string {
return "准备就绪"
}
// ReadRawJsonFile 供前端调用读取output_raw.json内容
// ReadRawJsonFile 已废弃请使用GetLatestParsedDataFromDatabase从数据库获取数据
func (a *App) ReadRawJsonFile() (*model.ParsedResult, error) {
data, err := ioutil.ReadFile("output_raw.json")
if err != nil {
return nil, err
}
return a.parserService.ReadRawJsonFile(string(data))
return a.GetLatestParsedDataFromDatabase()
}
// StopAndParseCapture 停止抓包并解析数据,供前端调用
@@ -161,5 +259,101 @@ func (a *App) StopAndParseCapture() (*model.ParsedResult, error) {
a.logger.Error("停止抓包并解析数据失败", "error", err)
return nil, err
}
// 将解析结果保存到数据库
if a.databaseService != nil && result != nil {
// 序列化装备数据
itemsJSON := "[]"
if result.Items != nil {
if jsonData, err := json.Marshal(result.Items); err == nil {
itemsJSON = string(jsonData)
}
}
// 序列化英雄数据
heroesJSON := "[]"
if result.Heroes != nil {
if jsonData, err := json.Marshal(result.Heroes); err == nil {
heroesJSON = string(jsonData)
}
}
// 保存到数据库
sessionName := fmt.Sprintf("capture_%d", time.Now().Unix())
if err := a.databaseService.SaveParsedDataToDatabase(sessionName, itemsJSON, heroesJSON); err != nil {
a.logger.Error("保存解析数据到数据库失败", "error", err)
// 不返回错误,因为解析成功了,只是保存失败
} else {
a.logger.Info("解析数据已保存到数据库", "session_name", sessionName)
}
}
return result, nil
}
// ========== 数据库相关API ==========
// SaveParsedDataToDatabase 保存解析后的数据到数据库
func (a *App) SaveParsedDataToDatabase(sessionName string, itemsJSON, heroesJSON string) error {
if a.databaseService == nil {
return fmt.Errorf("数据库服务未初始化")
}
return a.databaseService.SaveParsedDataToDatabase(sessionName, itemsJSON, heroesJSON)
}
// GetLatestParsedDataFromDatabase 从数据库获取最新的解析数据
func (a *App) GetLatestParsedDataFromDatabase() (*model.ParsedResult, error) {
if a.databaseService == nil {
return nil, fmt.Errorf("数据库服务未初始化")
}
itemsJSON, heroesJSON, err := a.databaseService.GetLatestParsedDataFromDatabase()
if err != nil {
return nil, err
}
// 解析装备数据
var items []interface{}
if itemsJSON != "" {
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
return nil, fmt.Errorf("解析装备数据失败: %w", err)
}
}
// 解析英雄数据
var heroes []interface{}
if heroesJSON != "" {
if err := json.Unmarshal([]byte(heroesJSON), &heroes); err != nil {
return nil, fmt.Errorf("解析英雄数据失败: %w", err)
}
}
return &model.ParsedResult{
Items: items,
Heroes: heroes,
}, nil
}
// SaveAppSetting 保存应用设置
func (a *App) SaveAppSetting(key, value string) error {
if a.databaseService == nil {
return fmt.Errorf("数据库服务未初始化")
}
return a.databaseService.SaveAppSetting(key, value)
}
// GetAppSetting 获取应用设置
func (a *App) GetAppSetting(key string) (string, error) {
if a.databaseService == nil {
return "", fmt.Errorf("数据库服务未初始化")
}
return a.databaseService.GetAppSetting(key)
}
// GetAllAppSettings 获取所有应用设置
func (a *App) GetAllAppSettings() (map[string]string, error) {
if a.databaseService == nil {
return nil, fmt.Errorf("数据库服务未初始化")
}
return a.databaseService.GetAllAppSettings()
}

File diff suppressed because it is too large Load Diff

View File

@@ -67,12 +67,8 @@ func (ps *ParserService) ParseHexData(hexDataList []string) (*model.ParsedResult
return nil, "", fmt.Errorf("远程json校验失败data字段缺失或为空")
}
// 校验通过再写入本地文件
fileErr := ioutil.WriteFile("output_raw.json", body, 0644)
if fileErr != nil {
ps.logger.Error("写入原始json文件失败", "error", fileErr)
}
ps.logger.Info("远程原始数据已写入output_raw.json")
// 校验通过,直接解析数据
ps.logger.Info("远程原始数据校验通过,开始解析")
parsedResult, err := ps.ReadRawJsonFile(string(body))
if err != nil {
return nil, "", err

View File

@@ -1,85 +1,13 @@
package service
import (
"encoding/json"
"equipment-analyzer/internal/config"
"equipment-analyzer/internal/utils"
"fmt"
"testing"
)
func TestReadRawJsonFile(t *testing.T) {
// 创建测试用的配置和日志器
config := &config.Config{}
logger := utils.NewLogger()
// 创建ParserService实例
ps := NewParserService(config, logger)
// 调用ReadRawJsonFile方法
result, err := ps.ReadRawJsonFile()
if err != nil {
t.Fatalf("ReadRawJsonFile failed: %v", err)
// 此测试已废弃因为ReadRawJsonFile方法已被移除
// 现在数据存储在SQLite数据库中不再依赖output_raw.json文件
t.Skip("ReadRawJsonFile测试已废弃数据现在存储在SQLite数据库中")
}
fmt.Printf("Raw result length: %d\n", len(result))
fmt.Printf("Raw result preview: %s\n", result[:min(200, len(result))])
// 解析JSON结果
var parsedData map[string]interface{}
if err := json.Unmarshal([]byte(result), &parsedData); err != nil {
t.Fatalf("Failed to parse JSON result: %v", err)
}
// 检查数据结构
fmt.Printf("Parsed data keys: %v\n", getKeys(parsedData))
// 检查items字段
if items, exists := parsedData["items"]; exists {
if itemsArray, ok := items.([]interface{}); ok {
fmt.Printf("Items count: %d\n", len(itemsArray))
if len(itemsArray) > 0 {
fmt.Printf("First item: %+v\n", itemsArray[0])
}
} else {
fmt.Printf("Items is not an array: %T\n", items)
}
} else {
fmt.Println("Items field not found")
}
// 检查heroes字段
if heroes, exists := parsedData["heroes"]; exists {
if heroesArray, ok := heroes.([]interface{}); ok {
fmt.Printf("Heroes count: %d\n", len(heroesArray))
if len(heroesArray) > 0 {
fmt.Printf("First hero: %+v\n", heroesArray[0])
}
} else {
fmt.Printf("Heroes is not an array: %T\n", heroes)
}
} else {
fmt.Println("Heroes field not found")
}
// 如果没有数据,输出更多调试信息
if len(result) < 100 {
fmt.Printf("Result seems empty or very short: %q\n", result)
}
}
// 辅助函数
func min(a, b int) int {
if a < b {
return a
}
return b
}
func getKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// 辅助函数已移除,因为测试已废弃

View File

@@ -13,7 +13,7 @@ type Logger struct {
func NewLogger() *Logger {
// 配置日志轮转
lumberJackLogger := &lumberjack.Logger{
Filename: "logs/equipment-analyzer.log",
Filename: "./logs/equipment-analyzer.log",
MaxSize: 100, // MB
MaxBackups: 3,
MaxAge: 28, // days

126588
opepic.json

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff