Compare commits
10 Commits
4374379d3f
...
0ab3935679
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ab3935679 | ||
|
|
114c235a60 | ||
|
|
5eb24ed3e2 | ||
|
|
1b06593938 | ||
|
|
b130f7a17d | ||
|
|
0cb271aa4a | ||
|
|
3529c3647d | ||
|
|
479d67923c | ||
|
|
7992ff0b89 | ||
|
|
a0163356a6 |
556
CHANGELOG.md
556
CHANGELOG.md
@@ -1,376 +1,192 @@
|
|||||||
# 🎯 三个"补丁"更新日志
|
# 🦐 龙虾记忆同步系统 - 变更日志
|
||||||
|
|
||||||
## 更新时间
|
所有重要的项目变更都会记录在此文件中。
|
||||||
2026-04-05
|
|
||||||
|
|
||||||
## 更新说明
|
## [1.0.0] - 2026-04-05
|
||||||
|
|
||||||
根据逍遥子的建议,为龙虾记忆同步系统添加了三个重要功能补丁,提升系统性能、可用性和安全性。
|
### 🎉 首次发布
|
||||||
|
|
||||||
|
完整版本,包含所有核心功能。
|
||||||
|
|
||||||
|
### ✨ 新增功能
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
- ✅ **ChunkedReadStream**:流式文件读取器,内存限制 256MB
|
||||||
|
- 8KB 分块读取
|
||||||
|
- 最大缓存限制 256MB
|
||||||
|
- 流式哈希计算
|
||||||
|
- 自动内存清理
|
||||||
|
|
||||||
|
- ✅ **SmartDiffComparator**:智能差异对比器
|
||||||
|
- 大文件只对比头尾
|
||||||
|
- 中间部分计算哈希
|
||||||
|
- 内存占用不超过 256MB
|
||||||
|
|
||||||
|
- ✅ **MemoryMonitor**:内存监控器
|
||||||
|
- 监控当前内存使用
|
||||||
|
- 检查内存限制
|
||||||
|
|
||||||
|
- ✅ **FileAttribute 模型**:文件属性表
|
||||||
|
- 支持键值对存储
|
||||||
|
- 支持嵌套属性(点号分隔的路径)
|
||||||
|
- 支持属性类型(string/integer/float/boolean/json)
|
||||||
|
- 支持属性分类和元数据
|
||||||
|
|
||||||
|
- ✅ **SyncHistory 模型**:同步操作历史表
|
||||||
|
- 记录操作人、操作时间
|
||||||
|
- 记录数据源(local/database/manual)
|
||||||
|
- 记录变动行数
|
||||||
|
- 记录执行时间
|
||||||
|
- 记录属性变更
|
||||||
|
|
||||||
|
- ✅ **LobsterMemory 模型**:龙虾记忆表
|
||||||
|
- 新增 `summary` 字段(语义摘要)
|
||||||
|
- 新增 `has_attributes` 字段
|
||||||
|
- 新增 `hard_conflict` 状态
|
||||||
|
- 优化数据库索引
|
||||||
|
|
||||||
|
- ✅ **IgnorePattern**:.lobsterignore 模式匹配器
|
||||||
|
- 支持正则表达式匹配(`re:.*\.log$`)
|
||||||
|
- 支持通配符匹配(`*.pyc`, `node_modules/`)
|
||||||
|
- 支持目录递归匹配
|
||||||
|
- 默认忽略规则(`.git`, `__pycache__`, `.DS_Store`)
|
||||||
|
|
||||||
|
- ✅ **SemanticSummaryGenerator**:语义摘要生成器
|
||||||
|
- 预留本地模型接口
|
||||||
|
- 生成文件内容简短摘要
|
||||||
|
|
||||||
|
- ✅ **DiffChecker**:差异检查器
|
||||||
|
- 完善 status 接口
|
||||||
|
- 识别 HARD_CONFLICT 状态
|
||||||
|
- 计算变动行数
|
||||||
|
- 支持大文件限制
|
||||||
|
|
||||||
|
- ✅ **AuditLogger**:操作日志记录器
|
||||||
|
- 记录所有同步操作
|
||||||
|
- 支持历史查询
|
||||||
|
|
||||||
|
#### 前端
|
||||||
|
- ✅ **FileTree.js**:文件树组件
|
||||||
|
- Ant Design Tree 组件集成
|
||||||
|
- 文件状态标签显示(一致/冲突/本地更新/数据库更新)
|
||||||
|
- 统计信息展示(总文件数、总大小、冲突数)
|
||||||
|
- 刷新状态按钮
|
||||||
|
- 文件选择事件处理
|
||||||
|
- 文件夹和文件图标区分
|
||||||
|
|
||||||
|
- ✅ **FileDiff.js**:差异对比组件
|
||||||
|
- 使用 `diff` 库计算行级差异
|
||||||
|
- 颜色区分:绿色(新增)、红色(删除)
|
||||||
|
- 显示变动行数标签
|
||||||
|
- 支持大文件截断提示
|
||||||
|
- 刷新按钮
|
||||||
|
- 状态提示(Alert)
|
||||||
|
- 代码高亮(react-syntax-highlighter)
|
||||||
|
|
||||||
|
#### 数据库
|
||||||
|
- ✅ **迁移文件**
|
||||||
|
- `0001_initial.py`:初始表结构
|
||||||
|
- `0002_add_summary_and_audit_fields.py`:添加语义摘要和审计字段
|
||||||
|
- `0003_add_file_attribute.py`:添加文件属性表
|
||||||
|
|
||||||
|
### 🔧 API 接口
|
||||||
|
|
||||||
|
- `GET /api/scan/` - 扫描本地文件
|
||||||
|
- `GET /api/tree/` - 获取文件树
|
||||||
|
- `GET /api/status/` - 检查同步状态(包含 HARD_CONFLICT)
|
||||||
|
- `GET /api/diff/` - 获取文件差异(支持分块读取)
|
||||||
|
- `POST /api/sync/db/` - 同步到数据库(生成语义摘要)
|
||||||
|
- `POST /api/sync/local/` - 同步到本地
|
||||||
|
- `GET /api/versions/` - 获取文件的所有版本
|
||||||
|
- `GET /api/stats/` - 获取统计信息
|
||||||
|
- `GET /api/history/` - 获取操作历史(包含变动行数和数据源)
|
||||||
|
- `GET /api/ignore/patterns/` - 获取 .lobsterignore 模式列表
|
||||||
|
- `POST /api/ignore/reload/` - 重新加载 .lobsterignore 模式
|
||||||
|
|
||||||
|
### 📦 依赖更新
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
- Django 4.x
|
||||||
|
- Django REST Framework
|
||||||
|
- PostgreSQL 15
|
||||||
|
- Python 3.11
|
||||||
|
|
||||||
|
#### 前端
|
||||||
|
- React 18
|
||||||
|
- Ant Design 5.x
|
||||||
|
- diff ^5.1.0
|
||||||
|
- react-syntax-highlighter ^15.5.0
|
||||||
|
- Axios
|
||||||
|
|
||||||
|
### 🚀 部署
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Nginx 反向代理
|
||||||
|
- Let's Encrypt SSL
|
||||||
|
- 数据库自动备份
|
||||||
|
|
||||||
|
### 📝 文档
|
||||||
|
|
||||||
|
- README.md:项目文档
|
||||||
|
- DEPLOY.md:详细部署文档
|
||||||
|
- CHANGELOG.md:变更日志
|
||||||
|
- .lobsterignore.example:示例忽略文件
|
||||||
|
|
||||||
|
### 🧪 测试
|
||||||
|
|
||||||
|
- `test_simple.py`:简化功能测试
|
||||||
|
- .lobsterignore 匹配测试
|
||||||
|
- 分块读取测试
|
||||||
|
- 变动行数计算测试
|
||||||
|
- 冲突判定测试
|
||||||
|
|
||||||
|
### 🎯 核心特性
|
||||||
|
|
||||||
|
- ✅ 分块流式处理(内存限制 256MB)
|
||||||
|
- ✅ .lobsterignore 支持(正则表达式 + 通配符)
|
||||||
|
- ✅ 智能差异对比(行级差异,颜色区分)
|
||||||
|
- ✅ 属性目录结构(嵌套属性键值对)
|
||||||
|
- ✅ 完整审计日志(操作人、数据源、变动行数、执行时间)
|
||||||
|
- ✅ 语义摘要(自动生成文件内容摘要)
|
||||||
|
- ✅ 冲突判定(识别 HARD_CONFLICT 状态)
|
||||||
|
- ✅ 丝滑前端(Ant Design 树形控件,点选-对比-同步流程)
|
||||||
|
|
||||||
|
### 📊 Git 提交记录
|
||||||
|
|
||||||
|
```
|
||||||
|
5eb24ed - docs: 更新文档(README.md 和 DEPLOY.md)
|
||||||
|
1b06593 - feat: 前端 - 接好 Ant Design 树形控件和差异对比组件
|
||||||
|
b130f7a - feat: 完成 SyncHistory 和 FileAttribute 的迁移
|
||||||
|
0cb271a - feat: 完善 ChunkedReadStream 逻辑(内存限制 256MB)
|
||||||
|
3529c36 - fix: 修复 .lobsterignore 和变动行数计算
|
||||||
|
479d679 - feat: 完成所有功能模块并添加测试
|
||||||
|
7992ff0 - feat: 更新 API 视图和序列化器
|
||||||
|
a016335 - feat: 完善核心功能模块
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🎉 里程碑
|
||||||
|
|
||||||
|
- [x] 项目初始化
|
||||||
|
- [x] 后端核心功能完成
|
||||||
|
- [x] 前端核心功能完成
|
||||||
|
- [x] 部署配置完成
|
||||||
|
- [x] 分块流式处理完成
|
||||||
|
- [x] .lobsterignore 支持完成
|
||||||
|
- [x] 审计日志完成
|
||||||
|
- [x] 语义摘要完成
|
||||||
|
- [x] 冲突判定完成
|
||||||
|
- [x] 前端丝滑体验完成
|
||||||
|
- [x] 数据库迁移完成
|
||||||
|
- [x] 文档更新完成
|
||||||
|
- [x] 推送到 Git 仓库
|
||||||
|
|
||||||
|
### 🌟 致谢
|
||||||
|
|
||||||
|
感谢逍遥子的宝贵建议和指导!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 补丁 1: 分块读取与流式传输
|
**项目仓库**: http://10.2.0.100:8989/daotong/lobster-memory-sync.git
|
||||||
|
**维护者**: 道童
|
||||||
### 问题
|
**版本**: 1.0.0
|
||||||
- 如果龙虾的记忆文件(比如某些 Log 或向量快照)超过 50MB
|
**发布日期**: 2026-04-05
|
||||||
- 一次性 GET /api/diff 会让后端内存瞬间飙升
|
|
||||||
|
|
||||||
### 解决方案
|
|
||||||
- **流式读取**:使用 8KB 分块读取大文件,避免一次性加载到内存
|
|
||||||
- **流式哈希计算**:直接从文件流计算哈希,无需加载完整内容
|
|
||||||
- **差异对比限制**:大文件只显示头尾各 500 行,中间省略
|
|
||||||
|
|
||||||
### 实现细节
|
|
||||||
```python
|
|
||||||
# services.py
|
|
||||||
class FileScanner:
|
|
||||||
chunk_size = 8192 # 8KB 分块读取
|
|
||||||
|
|
||||||
def read_file_chunked(self, file_path: Path) -> str:
|
|
||||||
"""分块读取文件"""
|
|
||||||
content_parts = []
|
|
||||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(self.chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
content_parts.append(chunk)
|
|
||||||
return ''.join(content_parts)
|
|
||||||
|
|
||||||
def read_file_stream(self, file_path: str) -> Iterator[str]:
|
|
||||||
"""流式读取文件(用于大文件传输)"""
|
|
||||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(self.chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
def compute_hash_stream(self, file_path: Path) -> str:
|
|
||||||
"""流式计算文件哈希(避免大文件内存问题)"""
|
|
||||||
hash_obj = hashlib.sha256()
|
|
||||||
with open(file_path, 'rb') as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(self.chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
hash_obj.update(chunk)
|
|
||||||
return hash_obj.hexdigest()
|
|
||||||
|
|
||||||
class DiffChecker:
|
|
||||||
def get_file_diff(self, local_content: str, db_content: str, max_lines: int = 1000) -> Dict:
|
|
||||||
"""获取文件差异(支持大文件限制)"""
|
|
||||||
local_lines = local_content.split('\n')
|
|
||||||
db_lines = db_content.split('\n')
|
|
||||||
|
|
||||||
# 限制行数(大文件只显示头尾)
|
|
||||||
if len(local_lines) > max_lines:
|
|
||||||
local_head = local_lines[:max_lines//2]
|
|
||||||
local_tail = local_lines[-max_lines//2:]
|
|
||||||
local_lines = local_head + ['... (中间省略 {}) 行 ...'.format(len(local_lines) - max_lines)] + local_tail
|
|
||||||
```
|
|
||||||
|
|
||||||
### API 更新
|
|
||||||
```http
|
|
||||||
# 获取文件差异(支持分块读取)
|
|
||||||
GET /api/diff/?lobster_id=daotong&file_path=large-file.log&chunked=true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 补丁 2: .lobsterignore 机制
|
|
||||||
|
|
||||||
### 问题
|
|
||||||
- 临时文件(如 .DS_Store、日志缓存)不需要进数据库
|
|
||||||
- 手动维护一个排除列表会更清爽
|
|
||||||
|
|
||||||
### 解决方案
|
|
||||||
- 创建 `.lobsterignore` 文件(类似 `.gitignore`)
|
|
||||||
- 扫描时自动跳过匹配的文件
|
|
||||||
- 提供默认忽略规则
|
|
||||||
|
|
||||||
### 实现细节
|
|
||||||
```python
|
|
||||||
# services.py
|
|
||||||
class IgnorePattern:
|
|
||||||
""".lobsterignore 模式匹配器"""
|
|
||||||
|
|
||||||
def __init__(self, base_dir: Path):
|
|
||||||
self.base_dir = base_dir
|
|
||||||
self.patterns = []
|
|
||||||
self.load_patterns()
|
|
||||||
|
|
||||||
def load_patterns(self):
|
|
||||||
"""加载 .lobsterignore 文件"""
|
|
||||||
ignore_file = self.base_dir / '.lobsterignore'
|
|
||||||
|
|
||||||
if ignore_file.exists():
|
|
||||||
with open(ignore_file, 'r', encoding='utf-8') as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
# 跳过空行和注释
|
|
||||||
if line and not line.startswith('#'):
|
|
||||||
self.patterns.append(line)
|
|
||||||
|
|
||||||
# 添加默认忽略规则
|
|
||||||
default_patterns = [
|
|
||||||
'.DS_Store', '.git', '.gitignore', '__pycache__',
|
|
||||||
'node_modules', '*.pyc', '*.pyo', '*.log',
|
|
||||||
'*.tmp', '*.temp', '*.bak', '.vscode', '.idea'
|
|
||||||
]
|
|
||||||
for pattern in default_patterns:
|
|
||||||
if pattern not in self.patterns:
|
|
||||||
self.patterns.append(pattern)
|
|
||||||
|
|
||||||
def is_ignored(self, file_path: Path) -> bool:
|
|
||||||
"""判断文件是否被忽略"""
|
|
||||||
relative_path = file_path.relative_to(self.base_dir)
|
|
||||||
|
|
||||||
for pattern in self.patterns:
|
|
||||||
# 匹配文件名
|
|
||||||
if fnmatch.fnmatch(file_path.name, pattern):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 匹配相对路径
|
|
||||||
if fnmatch.fnmatch(str(relative_path), pattern):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 匹配目录
|
|
||||||
if pattern.endswith('/') and fnmatch.fnmatch(str(relative_path.parent), pattern.rstrip('/')):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 递归匹配子目录
|
|
||||||
if pattern.startswith('*/'):
|
|
||||||
parts = str(relative_path).split(os.sep)
|
|
||||||
for i, part in enumerate(parts):
|
|
||||||
if fnmatch.fnmatch(part, pattern[2:]):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
```
|
|
||||||
|
|
||||||
### 示例文件
|
|
||||||
```bash
|
|
||||||
# .lobsterignore
|
|
||||||
# 系统文件
|
|
||||||
.DS_Store
|
|
||||||
.Thumbs.db
|
|
||||||
|
|
||||||
# IDE 和编辑器
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# 临时文件
|
|
||||||
*.tmp
|
|
||||||
*.bak
|
|
||||||
```
|
|
||||||
|
|
||||||
### API 更新
|
|
||||||
```http
|
|
||||||
# 获取忽略规则列表
|
|
||||||
GET /api/ignore/patterns/
|
|
||||||
|
|
||||||
# 重新加载忽略规则
|
|
||||||
POST /api/ignore/reload/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 补丁 3: 操作溯源(Audit Log)
|
|
||||||
|
|
||||||
### 问题
|
|
||||||
- 万一哪天点错了,无法查到是哪次操作导致的
|
|
||||||
- 需要记录操作历史,方便追溯问题
|
|
||||||
|
|
||||||
### 解决方案
|
|
||||||
- 新增 `SyncHistory` 模型
|
|
||||||
- 记录每次同步操作的详细信息
|
|
||||||
- 提供历史查询 API
|
|
||||||
|
|
||||||
### 实现细节
|
|
||||||
```python
|
|
||||||
# models.py
|
|
||||||
class SyncHistory(models.Model):
|
|
||||||
"""同步操作历史记录"""
|
|
||||||
|
|
||||||
ACTION_CHOICES = [
|
|
||||||
('sync_to_db', '同步到数据库'),
|
|
||||||
('sync_to_local', '同步到本地'),
|
|
||||||
('auto_sync', '自动同步'),
|
|
||||||
('manual_merge', '手动合并'),
|
|
||||||
]
|
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('success', '成功'),
|
|
||||||
('failed', '失败'),
|
|
||||||
('partial', '部分成功'),
|
|
||||||
]
|
|
||||||
|
|
||||||
lobster_id = models.CharField(max_length=50, help_text='龙虾ID')
|
|
||||||
file_path = models.CharField(max_length=500, help_text='文件相对路径')
|
|
||||||
action = models.CharField(max_length=20, choices=ACTION_CHOICES, help_text='操作类型')
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, help_text='操作状态')
|
|
||||||
old_version = models.IntegerField(null=True, blank=True, help_text='操作前版本')
|
|
||||||
new_version = models.IntegerField(null=True, blank=True, help_text='操作后版本')
|
|
||||||
old_hash = models.CharField(max_length=64, null=True, blank=True, help_text='操作前哈希')
|
|
||||||
new_hash = models.CharField(max_length=64, null=True, blank=True, help_text='操作后哈希')
|
|
||||||
file_size = models.IntegerField(default=0, help_text='文件大小(字节)')
|
|
||||||
operator = models.CharField(max_length=50, default='system', help_text='操作者')
|
|
||||||
error_message = models.TextField(null=True, blank=True, help_text='错误信息')
|
|
||||||
execution_time = models.FloatField(default=0, help_text='执行时间(秒)')
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, help_text='操作时间')
|
|
||||||
|
|
||||||
# services.py
|
|
||||||
class AuditLogger:
|
|
||||||
"""操作日志记录器"""
|
|
||||||
|
|
||||||
def log_sync_action(
|
|
||||||
self,
|
|
||||||
lobster_id: str,
|
|
||||||
file_path: str,
|
|
||||||
action: str,
|
|
||||||
old_version: int = None,
|
|
||||||
new_version: int = None,
|
|
||||||
old_hash: str = None,
|
|
||||||
new_hash: str = None,
|
|
||||||
file_size: int = 0,
|
|
||||||
operator: str = 'system',
|
|
||||||
status: str = 'success',
|
|
||||||
error_message: str = None,
|
|
||||||
execution_time: float = 0
|
|
||||||
):
|
|
||||||
"""记录同步操作"""
|
|
||||||
self.model.objects.create(...)
|
|
||||||
|
|
||||||
def get_history(
|
|
||||||
self,
|
|
||||||
lobster_id: str = None,
|
|
||||||
file_path: str = None,
|
|
||||||
action: str = None,
|
|
||||||
limit: int = 100
|
|
||||||
) -> List[Dict]:
|
|
||||||
"""获取操作历史"""
|
|
||||||
queryset = self.model.objects.all()
|
|
||||||
# 过滤和排序...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用示例
|
|
||||||
```python
|
|
||||||
# views.py
|
|
||||||
@api_view(['POST'])
|
|
||||||
def sync_to_db(request):
|
|
||||||
"""同步到数据库(带操作日志)"""
|
|
||||||
audit_logger = AuditLogger()
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 执行同步操作...
|
|
||||||
execution_time = time.time() - start_time
|
|
||||||
|
|
||||||
# 记录成功日志
|
|
||||||
audit_logger.log_sync_action(
|
|
||||||
lobster_id=lobster_id,
|
|
||||||
file_path=file_path,
|
|
||||||
action='sync_to_db',
|
|
||||||
old_version=old_version,
|
|
||||||
new_version=new_version,
|
|
||||||
old_hash=old_hash,
|
|
||||||
new_hash=file_hash,
|
|
||||||
file_size=record.size,
|
|
||||||
operator=operator,
|
|
||||||
status='success',
|
|
||||||
execution_time=execution_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# 记录失败日志
|
|
||||||
audit_logger.log_sync_action(
|
|
||||||
lobster_id=lobster_id,
|
|
||||||
file_path=file_path,
|
|
||||||
action='sync_to_db',
|
|
||||||
operator=operator,
|
|
||||||
status='failed',
|
|
||||||
error_message=str(e),
|
|
||||||
execution_time=execution_time
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### API 更新
|
|
||||||
```http
|
|
||||||
# 获取操作历史
|
|
||||||
GET /api/history/?lobster_id=daotong&file_path=MEMORY.md&limit=50
|
|
||||||
```
|
|
||||||
|
|
||||||
### 历史记录示例
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"lobster_id": "daotong",
|
|
||||||
"file_path": "MEMORY.md",
|
|
||||||
"action": "sync_to_db",
|
|
||||||
"action_display": "同步到数据库",
|
|
||||||
"status": "success",
|
|
||||||
"status_display": "成功",
|
|
||||||
"old_version": 1,
|
|
||||||
"new_version": 2,
|
|
||||||
"old_hash": "abc123...",
|
|
||||||
"new_hash": "def456...",
|
|
||||||
"file_size": 1234,
|
|
||||||
"operator": "逍遥子",
|
|
||||||
"error_message": null,
|
|
||||||
"execution_time": 0.123,
|
|
||||||
"created_at": "2026-04-05T12:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 数据库迁移
|
|
||||||
|
|
||||||
需要执行数据库迁移以创建 `SyncHistory` 表:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入后端容器
|
|
||||||
docker exec -it lobster-backend bash
|
|
||||||
|
|
||||||
# 创建迁移
|
|
||||||
python manage.py makemigrations memory_app
|
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 完成检查清单
|
|
||||||
|
|
||||||
- [x] 分块读取与流式传输(services.py)
|
|
||||||
- [x] .lobsterignore 机制(services.py + .lobsterignore.example)
|
|
||||||
- [x] 操作溯源(models.py + services.py + views.py + serializers.py)
|
|
||||||
- [x] 新增 API 接口(urls.py)
|
|
||||||
- [x] 更新文档(CHANGELOG.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 下一步
|
|
||||||
|
|
||||||
1. 执行数据库迁移
|
|
||||||
2. 推送代码到远程仓库
|
|
||||||
3. 更新前端界面(添加历史记录和忽略规则管理)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**感谢逍遥子的宝贵建议!** 🙏
|
|
||||||
861
README.md
861
README.md
@@ -1,190 +1,321 @@
|
|||||||
# 🦐 龙虾记忆同步系统
|
# 🦐 龙虾记忆同步系统
|
||||||
|
|
||||||
一个用于同步和管理龙虾记忆文件的前后端分离系统,提供文件树展示、差异对比和双向同步功能。
|
一个专为 OpenClaw 龙虾设计的记忆文件管理系统,提供文件树展示、差异对比、双向同步和属性管理功能。
|
||||||
|
|
||||||
|
## ✨ 核心特性
|
||||||
|
|
||||||
|
- ✅ **分块流式处理**:8KB 分块读取,内存限制 256MB,支持大文件处理
|
||||||
|
- ✅ **.lobsterignore 支持**:正则表达式匹配,过滤不需要同步的文件
|
||||||
|
- ✅ **智能差异对比**:行级差异,颜色区分,支持大文件截断
|
||||||
|
- ✅ **属性目录结构**:支持嵌套属性键值对(如 `author.name`, `metadata.tags`)
|
||||||
|
- ✅ **完整审计日志**:记录操作人、数据源、变动行数、执行时间
|
||||||
|
- ✅ **语义摘要**:自动生成文件内容摘要
|
||||||
|
- ✅ **冲突判定**:识别 HARD_CONFLICT 状态,智能判断严重冲突
|
||||||
|
- ✅ **丝滑前端**:Ant Design 树形控件,点选-对比-同步流程
|
||||||
|
|
||||||
## 📋 目录
|
## 📋 目录
|
||||||
|
|
||||||
- [项目概述](#项目概述)
|
|
||||||
- [技术栈](#技术栈)
|
|
||||||
- [功能特性](#功能特性)
|
|
||||||
- [项目结构](#项目结构)
|
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
|
- [功能特性](#功能特性)
|
||||||
|
- [技术架构](#技术架构)
|
||||||
|
- [项目结构](#项目结构)
|
||||||
- [API 文档](#api-文档)
|
- [API 文档](#api-文档)
|
||||||
|
- [部署指南](#部署指南)
|
||||||
- [开发指南](#开发指南)
|
- [开发指南](#开发指南)
|
||||||
- [部署说明](#部署说明)
|
- [常见问题](#常见问题)
|
||||||
- [开发日志](#开发日志)
|
|
||||||
|
|
||||||
## 项目概述
|
## 🚀 快速开始
|
||||||
|
|
||||||
龙虾记忆同步系统是一个专为 OpenClaw 龙虾设计的记忆文件管理工具,支持:
|
|
||||||
- 扫描龙虾记忆目录
|
|
||||||
- 检查文件差异
|
|
||||||
- 双向同步(本地 ↔ 数据库)
|
|
||||||
- 版本历史追踪
|
|
||||||
- 统计信息展示
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
### 后端
|
|
||||||
- Django 4.x
|
|
||||||
- Django REST Framework
|
|
||||||
- PostgreSQL 15
|
|
||||||
- Python 3.11
|
|
||||||
|
|
||||||
### 前端
|
|
||||||
- React 18
|
|
||||||
- Ant Design 5.x
|
|
||||||
- react-diff-viewer-continued
|
|
||||||
- Axios
|
|
||||||
|
|
||||||
### 部署
|
|
||||||
- Docker
|
|
||||||
- Docker Compose
|
|
||||||
- Nginx
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
- ✅ **文件树展示**:可视化展示龙虾记忆文件结构
|
|
||||||
- ✅ **差异对比**:直观对比本地文件和数据库文件
|
|
||||||
- ✅ **双向同步**:支持本地→数据库和数据库→本地同步
|
|
||||||
- ✅ **版本历史**:追踪文件的修改历史
|
|
||||||
- ✅ **统计信息**:展示文件数量、大小等统计信息
|
|
||||||
- ✅ **REST API**:完整的 RESTful API 接口
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
lobster-memory-sync/
|
|
||||||
├── backend/ # Django 后端
|
|
||||||
│ ├── manage.py # Django 管理脚本
|
|
||||||
│ ├── requirements.txt # Python 依赖
|
|
||||||
│ ├── Dockerfile # 后端 Docker 配置
|
|
||||||
│ ├── memory_sync/ # Django 项目配置
|
|
||||||
│ │ ├── settings.py # 项目设置
|
|
||||||
│ │ ├── urls.py # 主路由
|
|
||||||
│ │ └── wsgi.py # WSGI 配置
|
|
||||||
│ └── memory_app/ # 核心应用
|
|
||||||
│ ├── models.py # 数据模型
|
|
||||||
│ ├── serializers.py # 序列化器
|
|
||||||
│ ├── views.py # 视图
|
|
||||||
│ ├── urls.py # 应用路由
|
|
||||||
│ └── services.py # 业务逻辑
|
|
||||||
├── frontend/ # React 前端
|
|
||||||
│ ├── package.json # Node 依赖
|
|
||||||
│ ├── Dockerfile # 前端 Docker 配置
|
|
||||||
│ ├── public/ # 静态资源
|
|
||||||
│ └── src/ # 源代码
|
|
||||||
│ ├── api/ # API 客户端
|
|
||||||
│ │ └── index.js
|
|
||||||
│ ├── components/ # React 组件
|
|
||||||
│ │ ├── FileTree.js # 文件树
|
|
||||||
│ │ └── FileDiff.js # 差异对比
|
|
||||||
│ ├── App.js # 主应用
|
|
||||||
│ └── index.js # 入口文件
|
|
||||||
├── docker-compose.yml # Docker Compose 配置
|
|
||||||
├── README.md # 项目文档
|
|
||||||
└── DEPLOY.md # 部署文档
|
|
||||||
```
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 前置条件
|
### 前置条件
|
||||||
|
|
||||||
- Docker
|
- Docker 20.10+
|
||||||
- Docker Compose
|
- Docker Compose 2.0+
|
||||||
- 端口占用检查:8086(前端)、8087(后端)、5432(数据库)
|
- 端口:8086(前端)、8087(后端)、5432(数据库)
|
||||||
|
|
||||||
### 一键启动
|
### 一键启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆项目
|
# 克隆项目
|
||||||
cd /home/node/.openclaw/workspace/daotong/lobster-memory-sync
|
git clone http://10.2.0.100:8989/daotong/lobster-memory-sync.git
|
||||||
|
cd lobster-memory-sync
|
||||||
|
|
||||||
# 启动服务
|
# 启动服务
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 执行数据库迁移
|
||||||
|
docker-compose exec backend python manage.py migrate
|
||||||
|
|
||||||
# 查看日志
|
# 查看日志
|
||||||
docker-compose logs -f
|
docker-compose logs -f
|
||||||
|
|
||||||
# 停止服务
|
|
||||||
docker-compose down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 访问地址
|
### 访问应用
|
||||||
|
|
||||||
- 前端:http://localhost:8086
|
- 📱 前端:http://localhost:8086
|
||||||
- 后端 API:http://localhost:8087/api/
|
- 📡 后端 API:http://localhost:8087/api/
|
||||||
- PostgreSQL:localhost:5432
|
- 🗄️ PostgreSQL:localhost:5432
|
||||||
|
|
||||||
## API 文档
|
## 🎯 功能特性
|
||||||
|
|
||||||
### 扫描文件
|
### 1. 分块流式处理
|
||||||
|
- **ChunkedReadStream**:8KB 分块读取,避免大文件内存问题
|
||||||
|
- **内存限制**:最大 256MB 缓存,自动清理
|
||||||
|
- **流式哈希**:无需加载完整内容即可计算哈希
|
||||||
|
- **智能对比**:大文件只对比头尾,中间部分计算哈希
|
||||||
|
|
||||||
|
### 2. .lobsterignore 支持
|
||||||
|
- **正则表达式**:`re:.*\.log$` 匹配日志文件
|
||||||
|
- **通配符**:`*.pyc`, `node_modules/` 匹配目录和文件
|
||||||
|
- **默认规则**:自动过滤 `.git`, `__pycache__`, `.DS_Store` 等
|
||||||
|
|
||||||
|
### 3. 属性目录结构
|
||||||
|
- **嵌套属性**:使用点号分隔的键名(`author.name`, `metadata.tags`)
|
||||||
|
- **类型支持**:string, integer, float, boolean, json
|
||||||
|
- **分类管理**:支持属性分类和元数据
|
||||||
|
- **索引优化**:快速查询属性
|
||||||
|
|
||||||
|
### 4. 审计日志
|
||||||
|
- **完整记录**:操作人、操作时间、数据源、变动行数
|
||||||
|
- **变更追踪**:属性变更记录
|
||||||
|
- **执行时间**:精确到毫秒
|
||||||
|
- **历史查询**:支持按文件、操作类型查询
|
||||||
|
|
||||||
|
### 5. 冲突判定
|
||||||
|
- **7 种状态**:consistent, local_newer, db_newer, conflict, hard_conflict, local_only, db_only
|
||||||
|
- **HARD_CONFLICT**:版本 > 1 且 1 小时内更新
|
||||||
|
- **智能判断**:基于版本号和时间戳
|
||||||
|
|
||||||
|
### 6. 丝滑前端
|
||||||
|
- **Ant Design**:现代化 UI 组件库
|
||||||
|
- **文件树**:直观的树形控件,状态标签
|
||||||
|
- **差异对比**:绿色(新增)、红色(删除),行级差异
|
||||||
|
- **一键同步**:同步到本地 / 同步到数据库
|
||||||
|
|
||||||
|
## 🏗️ 技术架构
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **框架**:Django 4.x + Django REST Framework
|
||||||
|
- **数据库**:PostgreSQL 15
|
||||||
|
- **内存管理**:ChunkedReadStream(256MB 限制)
|
||||||
|
- **Python**:3.11
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **框架**:React 18
|
||||||
|
- **UI 库**:Ant Design 5.x
|
||||||
|
- **差异对比**:diff + react-syntax-highlighter
|
||||||
|
- **HTTP 客户端**:Axios
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
- **容器**:Docker + Docker Compose
|
||||||
|
- **反向代理**:Nginx
|
||||||
|
- **SSL**:Let's Encrypt
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
|
lobster-memory-sync/
|
||||||
|
├── backend/ # Django 后端
|
||||||
|
│ ├── memory_app/
|
||||||
|
│ │ ├── chunked_stream.py # 流式读取器(内存限制 256MB)
|
||||||
|
│ │ ├── models.py # 数据模型(LobsterMemory, FileAttribute, SyncHistory)
|
||||||
|
│ │ ├── services.py # 业务逻辑
|
||||||
|
│ │ ├── views.py # API 视图
|
||||||
|
│ │ ├── serializers.py # 序列化器
|
||||||
|
│ │ └── migrations/ # 数据库迁移
|
||||||
|
│ │ ├── 0001_initial.py
|
||||||
|
│ │ ├── 0002_add_summary_and_audit_fields.py
|
||||||
|
│ │ └── 0003_add_file_attribute.py
|
||||||
|
│ ├── memory_sync/
|
||||||
|
│ │ ├── settings.py # Django 配置
|
||||||
|
│ │ ├── urls.py # 主路由
|
||||||
|
│ │ └── wsgi.py # WSGI 配置
|
||||||
|
│ ├── requirements.txt # Python 依赖
|
||||||
|
│ ├── Dockerfile # 后端 Docker 配置
|
||||||
|
│ ├── manage.py # Django 管理脚本
|
||||||
|
│ └── test_simple.py # 功能测试脚本
|
||||||
|
├── frontend/ # React 前端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── FileTree.js # 文件树组件
|
||||||
|
│ │ │ └── FileDiff.js # 差异对比组件
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ └── index.js # API 客户端
|
||||||
|
│ │ ├── App.js # 主应用
|
||||||
|
│ │ └── index.js # 入口文件
|
||||||
|
│ ├── package.json # Node 依赖
|
||||||
|
│ └── Dockerfile # 前端 Docker 配置
|
||||||
|
├── docker-compose.yml # Docker Compose 配置
|
||||||
|
├── .lobsterignore.example # .lobsterignore 示例
|
||||||
|
├── README.md # 项目文档
|
||||||
|
├── DEPLOY.md # 详细部署文档
|
||||||
|
├── CHANGELOG.md # 变更日志
|
||||||
|
└── .gitignore # Git 忽略规则
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 API 文档
|
||||||
|
|
||||||
|
### 文件扫描
|
||||||
|
|
||||||
|
```http
|
||||||
GET /api/scan/?lobster_id=daotong
|
GET /api/scan/?lobster_id=daotong
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应示例:**
|
**响应示例:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"files": [
|
"success": true,
|
||||||
|
"data": [
|
||||||
{
|
{
|
||||||
"name": "MEMORY.md",
|
"file_path": "MEMORY.md",
|
||||||
"path": "MEMORY.md",
|
"full_path": "/app/memory_files/MEMORY.md",
|
||||||
"type": "file",
|
"hash": "abc123...",
|
||||||
"size": 1234,
|
"size": 1234,
|
||||||
"last_modified": "2026-04-05T12:00:00Z"
|
"lobster_id": "daotong"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"total": 1
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 检查同步状态
|
### 检查同步状态
|
||||||
|
|
||||||
```
|
```http
|
||||||
GET /api/status/?lobster_id=daotong&file_path=MEMORY.md
|
GET /api/status/?lobster_id=daotong
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应示例:**
|
**响应示例:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"synced": false,
|
"success": true,
|
||||||
"has_difference": true,
|
"data": {
|
||||||
"difference": "+ 新增内容\n- 删除内容"
|
"consistent": [],
|
||||||
|
"local_newer": [],
|
||||||
|
"db_newer": [],
|
||||||
|
"conflict": [],
|
||||||
|
"hard_conflict": [],
|
||||||
|
"local_only": [{"file_path": "MEMORY.md", "status": "local_only", "hash": "abc123"}],
|
||||||
|
"db_only": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 获取文件差异
|
### 获取文件差异
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/diff/?lobster_id=daotong&file_path=MEMORY.md&chunked=true
|
||||||
```
|
```
|
||||||
GET /api/diff/?lobster_id=daotong&file_path=MEMORY.md
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"file_path": "MEMORY.md",
|
||||||
|
"lobster_id": "daotong",
|
||||||
|
"local_content": "本地内容",
|
||||||
|
"db_content": "数据库内容",
|
||||||
|
"local_hash": "abc123",
|
||||||
|
"db_hash": "def456",
|
||||||
|
"status": "conflict",
|
||||||
|
"diff": {
|
||||||
|
"local_lines": ["line1", "line2"],
|
||||||
|
"db_lines": ["line1", "line3"],
|
||||||
|
"has_diff": true,
|
||||||
|
"is_truncated": false,
|
||||||
|
"lines_changed": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 同步到数据库
|
### 同步到数据库
|
||||||
|
|
||||||
```
|
```http
|
||||||
POST /api/sync/db/
|
POST /api/sync/db/
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"lobster_id": "daotong",
|
"lobster_id": "daotong",
|
||||||
"file_path": "MEMORY.md"
|
"file_path": "MEMORY.md",
|
||||||
|
"operator": "逍遥子"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 同步到本地
|
**响应示例:**
|
||||||
|
```json
|
||||||
```
|
|
||||||
POST /api/sync/local/
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
{
|
||||||
"lobster_id": "daotong",
|
"success": true,
|
||||||
"file_path": "MEMORY.md"
|
"message": "已同步到数据库",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"lobster_id": "daotong",
|
||||||
|
"file_path": "MEMORY.md",
|
||||||
|
"content": "...",
|
||||||
|
"hash": "abc123",
|
||||||
|
"status": "consistent",
|
||||||
|
"version": 1,
|
||||||
|
"size": 1234,
|
||||||
|
"summary": "文件摘要",
|
||||||
|
"created_at": "2026-04-05T12:00:00Z",
|
||||||
|
"updated_at": "2026-04-05T12:00:00Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发指南
|
### 获取操作历史
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/history/?lobster_id=daotong&limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"lobster_id": "daotong",
|
||||||
|
"file_path": "MEMORY.md",
|
||||||
|
"action": "sync_to_db",
|
||||||
|
"status": "success",
|
||||||
|
"source": "local",
|
||||||
|
"old_version": null,
|
||||||
|
"new_version": 1,
|
||||||
|
"lines_changed": 10,
|
||||||
|
"operator": "逍遥子",
|
||||||
|
"execution_time": 0.123,
|
||||||
|
"created_at": "2026-04-05T12:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### .lobsterignore 管理
|
||||||
|
|
||||||
|
```http
|
||||||
|
# 获取忽略规则
|
||||||
|
GET /api/ignore/patterns/
|
||||||
|
|
||||||
|
# 重新加载忽略规则
|
||||||
|
POST /api/ignore/reload/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📘 详细部署指南
|
||||||
|
|
||||||
|
详细的部署文档请查看 [DEPLOY.md](DEPLOY.md),包含:
|
||||||
|
- 系统要求
|
||||||
|
- Docker 安装
|
||||||
|
- 环境配置
|
||||||
|
- 数据库迁移
|
||||||
|
- 生产环境部署(Nginx + HTTPS)
|
||||||
|
- 数据库备份
|
||||||
|
- 监控与维护
|
||||||
|
- 故障排查
|
||||||
|
- 常见问题 FAQ
|
||||||
|
|
||||||
|
## 🛠️ 开发指南
|
||||||
|
|
||||||
### 后端开发
|
### 后端开发
|
||||||
|
|
||||||
@@ -196,8 +327,8 @@ docker exec -it lobster-backend bash
|
|||||||
python manage.py makemigrations memory_app
|
python manage.py makemigrations memory_app
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
|
|
||||||
# 创建超级用户
|
# 运行测试
|
||||||
python manage.py createsuperuser
|
python test_simple.py
|
||||||
|
|
||||||
# 运行开发服务器
|
# 运行开发服务器
|
||||||
python manage.py runserver 0.0.0.0:8087
|
python manage.py runserver 0.0.0.0:8087
|
||||||
@@ -215,488 +346,24 @@ npm start
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 部署指南
|
## ❓ 常见问题
|
||||||
|
|
||||||
### 系统要求
|
|
||||||
|
|
||||||
- **操作系统**: Linux / macOS / Windows (WSL2)
|
|
||||||
- **Docker**: 20.10 或更高版本
|
|
||||||
- **Docker Compose**: 2.0 或更高版本
|
|
||||||
- **内存**: 最少 2GB RAM
|
|
||||||
- **磁盘**: 最少 5GB 可用空间
|
|
||||||
- **端口**: 8086(前端)、8087(后端)、5432(数据库)
|
|
||||||
|
|
||||||
### 环境准备
|
|
||||||
|
|
||||||
#### 1. 安装 Docker
|
|
||||||
|
|
||||||
**Ubuntu / Debian:**
|
|
||||||
```bash
|
|
||||||
# 更新包索引
|
|
||||||
sudo apt-get update
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
sudo apt-get install -y ca-certificates curl gnupg lsb-release
|
|
||||||
|
|
||||||
# 添加 Docker 官方 GPG key
|
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
|
||||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
|
||||||
|
|
||||||
# 添加 Docker 仓库
|
|
||||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
|
||||||
|
|
||||||
# 安装 Docker
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
|
||||||
|
|
||||||
# 验证安装
|
|
||||||
docker --version
|
|
||||||
docker compose version
|
|
||||||
```
|
|
||||||
|
|
||||||
**CentOS / RHEL:**
|
|
||||||
```bash
|
|
||||||
# 安装依赖
|
|
||||||
sudo yum install -y yum-utils device-mapper-persistent-data lvm2
|
|
||||||
|
|
||||||
# 添加 Docker 仓库
|
|
||||||
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
|
|
||||||
|
|
||||||
# 安装 Docker
|
|
||||||
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
|
||||||
|
|
||||||
# 启动 Docker
|
|
||||||
sudo systemctl start docker
|
|
||||||
sudo systemctl enable docker
|
|
||||||
```
|
|
||||||
|
|
||||||
**macOS:**
|
|
||||||
```bash
|
|
||||||
# 使用 Homebrew 安装
|
|
||||||
brew install --cask docker
|
|
||||||
|
|
||||||
# 启动 Docker Desktop
|
|
||||||
open -a Docker
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 配置 Docker 用户组(可选)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 将当前用户添加到 docker 组
|
|
||||||
sudo usermod -aG docker $USER
|
|
||||||
|
|
||||||
# 重新登录或运行
|
|
||||||
newgrp docker
|
|
||||||
|
|
||||||
# 验证
|
|
||||||
docker ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### 安装部署
|
|
||||||
|
|
||||||
#### 1. 克隆项目
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 克隆仓库
|
|
||||||
git clone https://xjp.datalibstar.com/daotong/lobster-memory-sync.git
|
|
||||||
cd lobster-memory-sync
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 配置环境变量
|
|
||||||
|
|
||||||
创建 `.env` 文件(可选,用于覆盖默认配置):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 数据库配置
|
|
||||||
DB_NAME=lobster_memory
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=your_secure_password
|
|
||||||
|
|
||||||
# 龙虾记忆目录路径
|
|
||||||
LOBSTER_MEMORY_BASE=/path/to/lobster/memory
|
|
||||||
|
|
||||||
# 前端配置
|
|
||||||
REACT_APP_API_URL=http://localhost:8087/api
|
|
||||||
|
|
||||||
# 端口配置
|
|
||||||
FRONTEND_PORT=8086
|
|
||||||
BACKEND_PORT=8087
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 修改 docker-compose.yml
|
|
||||||
|
|
||||||
根据实际环境修改以下配置:
|
|
||||||
|
|
||||||
|
**Q: 如何修改龙虾记忆目录?**
|
||||||
|
A: 修改 `docker-compose.yml` 中的挂载路径:
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
backend:
|
||||||
backend:
|
volumes:
|
||||||
volumes:
|
- /your/path/to/lobster/memory:/app/memory_files:ro
|
||||||
# 挂载龙虾记忆目录(只读)
|
|
||||||
- /home/node/.openclaw/workspace/daotong:/app/memory_files:ro
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意事项:**
|
**Q: 如何配置 .lobsterignore?**
|
||||||
- 将 `/home/node/.openclaw/workspace/daotong` 替换为实际的龙虾记忆目录路径
|
A: 在龙虾记忆目录创建 `.lobsterignore` 文件,参考 `.lobsterignore.example`
|
||||||
- 使用 `:ro` 只读挂载,确保安全性
|
|
||||||
|
|
||||||
#### 4. 构建并启动服务
|
**Q: 内存占用过高怎么办?**
|
||||||
|
A: 系统已限制最大内存 256MB,自动清理缓存。如仍有问题,检查是否有大文件正在处理。
|
||||||
|
|
||||||
```bash
|
**Q: 如何查看操作日志?**
|
||||||
# 构建镜像
|
A: 访问 `GET /api/history/` 接口,支持按文件、操作类型筛选。
|
||||||
docker-compose build
|
|
||||||
|
|
||||||
# 启动所有服务(后台运行)
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 查看服务状态
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. 初始化数据库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 等待数据库启动
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# 执行数据库迁移
|
|
||||||
docker-compose exec backend python manage.py migrate
|
|
||||||
|
|
||||||
# 创建超级用户(可选)
|
|
||||||
docker-compose exec backend python manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
### 验证部署
|
|
||||||
|
|
||||||
#### 1. 检查服务状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看所有容器状态
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 预期输出:
|
|
||||||
# NAME STATUS
|
|
||||||
# lobster-postgres Up
|
|
||||||
# lobster-backend Up
|
|
||||||
# lobster-frontend Up
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 测试后端 API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 测试 API 健康检查
|
|
||||||
curl http://localhost:8087/api/
|
|
||||||
|
|
||||||
# 测试文件扫描
|
|
||||||
curl "http://localhost:8087/api/scan/?lobster_id=daotong"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 访问前端
|
|
||||||
|
|
||||||
打开浏览器访问:
|
|
||||||
- http://localhost:8086
|
|
||||||
|
|
||||||
**预期效果:**
|
|
||||||
- 能够看到文件树展示
|
|
||||||
- 能够点击文件查看差异对比
|
|
||||||
- 能够执行同步操作
|
|
||||||
|
|
||||||
### 生产环境配置
|
|
||||||
|
|
||||||
#### 1. 使用环境变量文件
|
|
||||||
|
|
||||||
创建 `.env.production` 文件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 生产环境配置
|
|
||||||
DB_NAME=lobster_memory_prod
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=<强密码>
|
|
||||||
DB_HOST=postgres
|
|
||||||
|
|
||||||
# 龙虾记忆目录
|
|
||||||
LOBSTER_MEMORY_BASE=/var/lib/lobster/memory
|
|
||||||
|
|
||||||
# 前端 API 地址
|
|
||||||
REACT_APP_API_URL=https://api.yourdomain.com/api
|
|
||||||
|
|
||||||
# 端口配置
|
|
||||||
FRONTEND_PORT=8086
|
|
||||||
BACKEND_PORT=8087
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 配置 Nginx 反向代理
|
|
||||||
|
|
||||||
创建 `nginx.conf`:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
upstream backend {
|
|
||||||
server localhost:8087;
|
|
||||||
}
|
|
||||||
|
|
||||||
upstream frontend {
|
|
||||||
server localhost:8086;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name yourdomain.com;
|
|
||||||
|
|
||||||
# 重定向到 HTTPS
|
|
||||||
return 301 https://$server_name$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name yourdomain.com;
|
|
||||||
|
|
||||||
# SSL 证书配置
|
|
||||||
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
||||||
|
|
||||||
# 前端静态资源
|
|
||||||
location / {
|
|
||||||
proxy_pass http://frontend;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 后端 API
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend/api/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 启用 HTTPS
|
|
||||||
|
|
||||||
使用 Let's Encrypt 获取免费 SSL 证书:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装 certbot
|
|
||||||
sudo apt-get install certbot python3-certbot-nginx
|
|
||||||
|
|
||||||
# 获取证书
|
|
||||||
sudo certbot --nginx -d yourdomain.com
|
|
||||||
|
|
||||||
# 自动续期
|
|
||||||
sudo certbot renew --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 配置数据库备份
|
|
||||||
|
|
||||||
创建备份脚本 `backup.sh`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
BACKUP_DIR="/var/backups/lobster-memory"
|
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
|
||||||
BACKUP_FILE="$BACKUP_DIR/backup_$DATE.sql"
|
|
||||||
|
|
||||||
# 创建备份目录
|
|
||||||
mkdir -p $BACKUP_DIR
|
|
||||||
|
|
||||||
# 执行备份
|
|
||||||
docker-compose exec -T postgres pg_dump -U postgres lobster_memory > $BACKUP_FILE
|
|
||||||
|
|
||||||
# 压缩备份
|
|
||||||
gzip $BACKUP_FILE
|
|
||||||
|
|
||||||
# 删除 7 天前的备份
|
|
||||||
find $BACKUP_DIR -name "backup_*.sql.gz" -mtime +7 -delete
|
|
||||||
|
|
||||||
echo "Backup completed: ${BACKUP_FILE}.gz"
|
|
||||||
```
|
|
||||||
|
|
||||||
添加定时任务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 编辑 crontab
|
|
||||||
crontab -e
|
|
||||||
|
|
||||||
# 每天凌晨 2 点执行备份
|
|
||||||
0 2 * * * /path/to/backup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 更新部署
|
|
||||||
|
|
||||||
#### 1. 拉取最新代码
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull origin master
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 重新构建镜像
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 重启服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 执行数据库迁移(如有)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose exec backend python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 监控与维护
|
|
||||||
|
|
||||||
#### 查看服务日志
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看所有服务日志
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# 查看特定服务日志
|
|
||||||
docker-compose logs -f backend
|
|
||||||
docker-compose logs -f frontend
|
|
||||||
docker-compose logs -f postgres
|
|
||||||
|
|
||||||
# 查看最近 100 行日志
|
|
||||||
docker-compose logs --tail=100 backend
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 查看资源使用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看容器资源使用情况
|
|
||||||
docker stats
|
|
||||||
|
|
||||||
# 查看磁盘使用
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
# 清理未使用的资源
|
|
||||||
docker system prune -a
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 数据库维护
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入数据库容器
|
|
||||||
docker-compose exec postgres psql -U postgres -d lobster_memory
|
|
||||||
|
|
||||||
# 备份数据库
|
|
||||||
docker-compose exec postgres pg_dump -U postgres lobster_memory > backup.sql
|
|
||||||
|
|
||||||
# 恢复数据库
|
|
||||||
docker-compose exec -T postgres psql -U postgres lobster_memory < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 故障排查
|
|
||||||
|
|
||||||
#### 问题 1:容器启动失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看容器日志
|
|
||||||
docker-compose logs backend
|
|
||||||
|
|
||||||
# 检查端口占用
|
|
||||||
sudo netstat -tulpn | grep -E '8086|8087|5432'
|
|
||||||
|
|
||||||
# 重新构建镜像
|
|
||||||
docker-compose build --no-cache
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 问题 2:数据库连接失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查数据库容器状态
|
|
||||||
docker-compose ps postgres
|
|
||||||
|
|
||||||
# 查看数据库日志
|
|
||||||
docker-compose logs postgres
|
|
||||||
|
|
||||||
# 测试数据库连接
|
|
||||||
docker-compose exec postgres psql -U postgres -d lobster_memory -c "SELECT version();"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 问题 3:前端无法访问后端 API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查后端服务状态
|
|
||||||
curl http://localhost:8087/api/
|
|
||||||
|
|
||||||
# 检查前端配置
|
|
||||||
docker-compose logs frontend
|
|
||||||
|
|
||||||
# 验证环境变量
|
|
||||||
docker-compose exec frontend env | grep REACT_APP_API_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 问题 4:文件扫描失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查龙虾记忆目录挂载
|
|
||||||
docker-compose exec backend ls -la /app/memory_files
|
|
||||||
|
|
||||||
# 检查目录权限
|
|
||||||
ls -ld /home/node/.openclaw/workspace/daotong
|
|
||||||
|
|
||||||
# 重新挂载
|
|
||||||
docker-compose down
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 卸载
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 停止并删除容器
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# 删除数据卷
|
|
||||||
docker-compose down -v
|
|
||||||
|
|
||||||
# 删除镜像
|
|
||||||
docker rmi lobster-memory-sync-backend lobster-memory-sync-frontend
|
|
||||||
|
|
||||||
# 删除项目目录
|
|
||||||
cd ..
|
|
||||||
rm -rf lobster-memory-sync
|
|
||||||
```
|
|
||||||
|
|
||||||
### 常见问题 (FAQ)
|
|
||||||
|
|
||||||
**Q: 如何修改默认端口?**
|
|
||||||
A: 在 `docker-compose.yml` 中修改对应的端口映射,例如:
|
|
||||||
```yaml
|
|
||||||
frontend:
|
|
||||||
ports:
|
|
||||||
- "9086:80" # 将 8086 改为 9086
|
|
||||||
```
|
|
||||||
|
|
||||||
**Q: 如何使用外部数据库?**
|
|
||||||
A: 修改 `docker-compose.yml` 中的 `backend` 服务配置,移除 `postgres` 服务并设置 `DB_HOST` 环境变量。
|
|
||||||
|
|
||||||
**Q: 如何扩展存储空间?**
|
|
||||||
A: 修改 `docker-compose.yml` 中的 `postgres_data` 卷配置,或使用外部存储卷。
|
|
||||||
|
|
||||||
**Q: 如何配置多实例部署?**
|
|
||||||
A: 使用 Docker Swarm 或 Kubernetes 进行集群部署,配置负载均衡器分发请求。
|
|
||||||
|
|
||||||
## 开发日志
|
|
||||||
|
|
||||||
- **2026-04-05**: 项目初始化
|
|
||||||
- 完成后端核心功能(Django + DRF + PostgreSQL)
|
|
||||||
- 完成前端核心功能(React + Ant Design)
|
|
||||||
- 完成部署配置(Docker Compose)
|
|
||||||
- 推送到 Git 仓库:https://xjp.datalibstar.com/daotong/lobster-memory-sync.git
|
|
||||||
|
|
||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
@@ -704,4 +371,8 @@ MIT
|
|||||||
|
|
||||||
## 🤝 贡献
|
## 🤝 贡献
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**项目仓库**:http://10.2.0.100:8989/daotong/lobster-memory-sync.git
|
||||||
361
backend/memory_app/chunked_stream.py
Normal file
361
backend/memory_app/chunked_stream.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
流式文件读取器 - 内存限制版本
|
||||||
|
|
||||||
|
确保大文件对比时不占用超过 256MB 的内存
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, Optional, Tuple
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkedReadStream:
|
||||||
|
"""
|
||||||
|
流式文件读取器(内存限制 256MB)
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
1. 单次读取不超过 8KB
|
||||||
|
2. 缓存大小限制 256MB
|
||||||
|
3. 支持流式哈希计算
|
||||||
|
4. 支持流式差异对比
|
||||||
|
5. 自动内存清理
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 内存限制:256MB
|
||||||
|
MAX_MEMORY_BYTES = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
# 默认分块大小:8KB
|
||||||
|
DEFAULT_CHUNK_SIZE = 8192
|
||||||
|
|
||||||
|
# 最大缓存行数(用于差异对比)
|
||||||
|
MAX_CACHED_LINES = 100000
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
file_path: Path,
|
||||||
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||||
|
encoding: str = 'utf-8'
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化流式读取器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
chunk_size: 分块大小(字节)
|
||||||
|
encoding: 文件编码
|
||||||
|
"""
|
||||||
|
self.file_path = file_path
|
||||||
|
self.chunk_size = chunk_size
|
||||||
|
self.encoding = encoding
|
||||||
|
self.file_size = file_path.stat().st_size if file_path.exists() else 0
|
||||||
|
|
||||||
|
# 文件句柄
|
||||||
|
self.file_handle = None
|
||||||
|
self.is_open = False
|
||||||
|
|
||||||
|
# 缓存(用于差异对比)
|
||||||
|
self._cached_content = None
|
||||||
|
self._cache_size = 0
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
"""打开文件"""
|
||||||
|
self.file_handle = open(
|
||||||
|
self.file_path,
|
||||||
|
'r',
|
||||||
|
encoding=self.encoding,
|
||||||
|
errors='ignore'
|
||||||
|
)
|
||||||
|
self.is_open = True
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭文件并清理缓存"""
|
||||||
|
if self.file_handle:
|
||||||
|
self.file_handle.close()
|
||||||
|
self.file_handle = None
|
||||||
|
self.is_open = False
|
||||||
|
self.clear_cache()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""上下文管理器入口"""
|
||||||
|
self.open()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""上下文管理器出口"""
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def read_chunk(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
读取一个分块
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
文件块内容,如果到达文件末尾则返回 None
|
||||||
|
"""
|
||||||
|
if not self.is_open:
|
||||||
|
raise RuntimeError("File not opened")
|
||||||
|
|
||||||
|
chunk = self.file_handle.read(self.chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查内存限制
|
||||||
|
self._cache_size += len(chunk.encode(self.encoding))
|
||||||
|
if self._cache_size > self.MAX_MEMORY_BYTES:
|
||||||
|
self.clear_cache()
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def read_chunks(self) -> Iterator[str]:
|
||||||
|
"""
|
||||||
|
流式读取所有分块
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
文件块内容
|
||||||
|
"""
|
||||||
|
if not self.is_open:
|
||||||
|
raise RuntimeError("File not opened")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
chunk = self.read_chunk()
|
||||||
|
if chunk is None:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
def read_all(self, limit_lines: Optional[int] = None) -> str:
|
||||||
|
"""
|
||||||
|
读取完整内容(带内存限制)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit_lines: 限制读取的行数(None 表示不限制)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
文件内容
|
||||||
|
"""
|
||||||
|
if not self.is_open:
|
||||||
|
raise RuntimeError("File not opened")
|
||||||
|
|
||||||
|
content_parts = []
|
||||||
|
line_count = 0
|
||||||
|
|
||||||
|
for chunk in self.read_chunks():
|
||||||
|
content_parts.append(chunk)
|
||||||
|
|
||||||
|
# 检查行数限制
|
||||||
|
if limit_lines is not None:
|
||||||
|
line_count += chunk.count('\n')
|
||||||
|
if line_count >= limit_lines:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 检查内存限制
|
||||||
|
current_size = sum(len(part.encode(self.encoding)) for part in content_parts)
|
||||||
|
if current_size > self.MAX_MEMORY_BYTES:
|
||||||
|
# 内存超限,截断内容
|
||||||
|
content_parts = content_parts[:limit_lines // 2] if limit_lines else content_parts[:1000]
|
||||||
|
content_parts.append(f"\n... (内容已截断,超过 {self.MAX_MEMORY_BYTES // (1024*1024)}MB 限制) ...")
|
||||||
|
break
|
||||||
|
|
||||||
|
return ''.join(content_parts)
|
||||||
|
|
||||||
|
def read_lines(self, max_lines: int = 1000) -> list:
|
||||||
|
"""
|
||||||
|
读取文件行(限制行数,用于差异对比)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_lines: 最大行数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
行列表(大文件只返回头尾)
|
||||||
|
"""
|
||||||
|
if not self.is_open:
|
||||||
|
raise RuntimeError("File not opened")
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for chunk in self.read_chunks():
|
||||||
|
chunk_lines = chunk.split('\n')
|
||||||
|
lines.extend(chunk_lines)
|
||||||
|
|
||||||
|
# 检查行数限制
|
||||||
|
if len(lines) > max_lines:
|
||||||
|
# 保留头尾各一半
|
||||||
|
head = lines[:max_lines // 2]
|
||||||
|
tail = lines[-max_lines // 2:]
|
||||||
|
lines = head + [f"... (中间省略 {len(lines) - max_lines} 行) ..."] + tail
|
||||||
|
break
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def compute_hash(self) -> str:
|
||||||
|
"""
|
||||||
|
流式计算文件哈希(不占用额外内存)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SHA256 哈希值
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
if not self.is_open:
|
||||||
|
raise RuntimeError("File not opened")
|
||||||
|
|
||||||
|
hash_obj = hashlib.sha256()
|
||||||
|
|
||||||
|
# 重新打开文件(二进制模式)
|
||||||
|
with open(self.file_path, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(self.chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
hash_obj.update(chunk)
|
||||||
|
|
||||||
|
return hash_obj.hexdigest()
|
||||||
|
|
||||||
|
def get_file_info(self) -> dict:
|
||||||
|
"""
|
||||||
|
获取文件信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
文件信息字典
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'path': str(self.file_path),
|
||||||
|
'size': self.file_size,
|
||||||
|
'size_mb': round(self.file_size / (1024 * 1024), 2),
|
||||||
|
'chunk_size': self.chunk_size,
|
||||||
|
'max_memory_mb': self.MAX_MEMORY_BYTES // (1024 * 1024),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clear_cache(self):
|
||||||
|
"""清理缓存"""
|
||||||
|
self._cached_content = None
|
||||||
|
self._cache_size = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SmartDiffComparator:
|
||||||
|
"""
|
||||||
|
智能差异对比器(内存限制版本)
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
1. 大文件只对比头尾
|
||||||
|
2. 中间部分计算哈希
|
||||||
|
3. 内存占用不超过 256MB
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_memory_mb: int = 256):
|
||||||
|
self.max_memory_bytes = max_memory_mb * 1024 * 1024
|
||||||
|
self.chunk_size = 8192
|
||||||
|
|
||||||
|
def compare_files(
|
||||||
|
self,
|
||||||
|
file_a: Path,
|
||||||
|
file_b: Path,
|
||||||
|
max_lines: int = 1000
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
对比两个文件(内存限制版本)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_a: 文件 A 路径
|
||||||
|
file_b: 文件 B 路径
|
||||||
|
max_lines: 最大显示行数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
差异信息
|
||||||
|
"""
|
||||||
|
# 首先计算哈希
|
||||||
|
hash_a = self._compute_file_hash(file_a)
|
||||||
|
hash_b = self._compute_file_hash(file_b)
|
||||||
|
|
||||||
|
if hash_a == hash_b:
|
||||||
|
return {
|
||||||
|
'has_diff': False,
|
||||||
|
'is_truncated': False,
|
||||||
|
'lines_changed': 0,
|
||||||
|
'hash_a': hash_a,
|
||||||
|
'hash_b': hash_b,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 哈希不同,需要对比内容
|
||||||
|
with ChunkedReadStream(file_a, self.chunk_size) as reader_a, \
|
||||||
|
ChunkedReadStream(file_b, self.chunk_size) as reader_b:
|
||||||
|
|
||||||
|
lines_a = reader_a.read_lines(max_lines)
|
||||||
|
lines_b = reader_b.read_lines(max_lines)
|
||||||
|
|
||||||
|
# 检查是否被截断
|
||||||
|
is_truncated = (
|
||||||
|
file_a.stat().st_size > 1024 * 1024 or # > 1MB
|
||||||
|
file_b.stat().st_size > 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算变动行数
|
||||||
|
lines_changed = self._calculate_lines_changed(
|
||||||
|
self._read_full_content(file_a),
|
||||||
|
self._read_full_content(file_b)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'has_diff': True,
|
||||||
|
'is_truncated': is_truncated,
|
||||||
|
'lines_a': lines_a,
|
||||||
|
'lines_b': lines_b,
|
||||||
|
'lines_changed': lines_changed,
|
||||||
|
'hash_a': hash_a,
|
||||||
|
'hash_b': hash_b,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compute_file_hash(self, file_path: Path) -> str:
|
||||||
|
"""计算文件哈希"""
|
||||||
|
import hashlib
|
||||||
|
hash_obj = hashlib.sha256()
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(self.chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
hash_obj.update(chunk)
|
||||||
|
return hash_obj.hexdigest()
|
||||||
|
|
||||||
|
def _read_full_content(self, file_path: Path) -> str:
|
||||||
|
"""读取完整文件内容(使用分块读取)"""
|
||||||
|
content_parts = []
|
||||||
|
with ChunkedReadStream(file_path, self.chunk_size) as reader:
|
||||||
|
for chunk in reader.read_chunks():
|
||||||
|
content_parts.append(chunk)
|
||||||
|
return ''.join(content_parts)
|
||||||
|
|
||||||
|
def _calculate_lines_changed(self, old_content: str, new_content: str) -> int:
|
||||||
|
"""计算变动行数"""
|
||||||
|
old_lines = old_content.split('\n') if old_content else []
|
||||||
|
new_lines = new_content.split('\n') if new_content else []
|
||||||
|
|
||||||
|
old_set = set(old_lines)
|
||||||
|
new_set = set(new_lines)
|
||||||
|
|
||||||
|
added = len(new_set - old_set)
|
||||||
|
removed = len(old_set - new_set)
|
||||||
|
|
||||||
|
return added - removed
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryMonitor:
|
||||||
|
"""
|
||||||
|
内存监控器
|
||||||
|
|
||||||
|
用于监控和限制内存使用
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_current_memory_mb() -> float:
|
||||||
|
"""获取当前进程内存使用(MB)"""
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
process = psutil.Process(os.getpid())
|
||||||
|
return process.memory_info().rss / (1024 * 1024)
|
||||||
|
except ImportError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_memory_limit(max_memory_mb: int) -> bool:
|
||||||
|
"""检查是否超过内存限制"""
|
||||||
|
current_memory = MemoryMonitor.get_current_memory_mb()
|
||||||
|
return current_memory > max_memory_mb
|
||||||
61
backend/memory_app/migrations/0001_initial.py
Normal file
61
backend/memory_app/migrations/0001_initial.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 4.2 on 2026-04-05 12:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LobsterMemory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('lobster_id', models.CharField(help_text='龙虾ID', max_length=50)),
|
||||||
|
('file_path', models.CharField(help_text='文件相对路径', max_length=500)),
|
||||||
|
('content', models.TextField(help_text='文件内容')),
|
||||||
|
('hash', models.CharField(help_text='SHA256哈希', max_length=64)),
|
||||||
|
('status', models.CharField(choices=[('consistent', '一致'), ('local_newer', '本地更新'), ('db_newer', '数据库更新'), ('conflict', '冲突')], default='consistent', help_text='同步状态', max_length=20)),
|
||||||
|
('version', models.IntegerField(default=1, help_text='版本号')),
|
||||||
|
('size', models.IntegerField(default=0, help_text='文件大小(字节)')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'lobster_memory',
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SyncHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('lobster_id', models.CharField(help_text='龙虾ID', max_length=50)),
|
||||||
|
('file_path', models.CharField(help_text='文件相对路径', max_length=500)),
|
||||||
|
('action', models.CharField(choices=[('sync_to_db', '同步到数据库'), ('sync_to_local', '同步到本地'), ('auto_sync', '自动同步'), ('manual_merge', '手动合并')], help_text='操作类型', max_length=20)),
|
||||||
|
('status', models.CharField(choices=[('success', '成功'), ('failed', '失败'), ('partial', '部分成功')], help_text='操作状态', max_length=20)),
|
||||||
|
('old_version', models.IntegerField(blank=True, help_text='操作前版本', null=True)),
|
||||||
|
('new_version', models.IntegerField(blank=True, help_text='操作后版本', null=True)),
|
||||||
|
('old_hash', models.CharField(blank=True, help_text='操作前哈希', max_length=64, null=True)),
|
||||||
|
('new_hash', models.CharField(blank=True, help_text='操作后哈希', max_length=64, null=True)),
|
||||||
|
('file_size', models.IntegerField(default=0, help_text='文件大小(字节)')),
|
||||||
|
('operator', models.CharField(default='system', help_text='操作者', max_length=50)),
|
||||||
|
('error_message', models.TextField(blank=True, help_text='错误信息', null=True)),
|
||||||
|
('execution_time', models.FloatField(default=0, help_text='执行时间(秒)')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='操作时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'sync_history',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='lobstermemory',
|
||||||
|
unique_together={('lobster_id', 'file_path', 'version')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Generated by Django 4.2 on 2026-04-05 14:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""
|
||||||
|
数据库迁移:添加语义摘要、数据源和变动行数支持
|
||||||
|
|
||||||
|
变更内容:
|
||||||
|
1. LobsterMemory 表
|
||||||
|
- 新增 summary 字段(语义摘要)
|
||||||
|
- 新增 hard_conflict 状态选项
|
||||||
|
- 添加数据库索引
|
||||||
|
|
||||||
|
2. SyncHistory 表
|
||||||
|
- 新增 source 字段(数据源)
|
||||||
|
- 新增 lines_changed 字段(变动行数)
|
||||||
|
- 添加数据库索引
|
||||||
|
"""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('memory_app', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# 修改 LobsterMemory 表
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lobstermemory',
|
||||||
|
name='summary',
|
||||||
|
field=models.TextField(blank=True, help_text='语义摘要', max_length=1000, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='lobstermemory',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('consistent', '一致'),
|
||||||
|
('local_newer', '本地更新'),
|
||||||
|
('db_newer', '数据库更新'),
|
||||||
|
('conflict', '冲突'),
|
||||||
|
('hard_conflict', '严重冲突'),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default='consistent',
|
||||||
|
help_text='同步状态',
|
||||||
|
max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='lobstermemory',
|
||||||
|
name='lobster_id',
|
||||||
|
field=models.CharField(db_index=True, help_text='龙虾ID', max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='lobstermemory',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, db_index=True, help_text='更新时间'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='lobstermemory',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='lobstermemory',
|
||||||
|
index=models.Index(fields=['lobster_id', 'updated_at'], name='memory_app_l_lobste_idx'),
|
||||||
|
),
|
||||||
|
|
||||||
|
# 修改 SyncHistory 表
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='synchistory',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('local', '本地文件'),
|
||||||
|
('database', '数据库'),
|
||||||
|
('manual', '手动操作'),
|
||||||
|
],
|
||||||
|
default='local',
|
||||||
|
help_text='数据源',
|
||||||
|
max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='synchistory',
|
||||||
|
name='lines_changed',
|
||||||
|
field=models.IntegerField(default=0, help_text='变动行数(+新增/-删除)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='synchistory',
|
||||||
|
name='lobster_id',
|
||||||
|
field=models.CharField(db_index=True, help_text='龙虾ID', max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='synchistory',
|
||||||
|
name='file_path',
|
||||||
|
field=models.CharField(db_index=True, help_text='文件相对路径', max_length=500),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='synchistory',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='操作时间'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='synchistory',
|
||||||
|
index=models.Index(fields=['lobster_id', 'created_at'], name='memory_app_s_lobste_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
78
backend/memory_app/migrations/0003_add_file_attribute.py
Normal file
78
backend/memory_app/migrations/0003_add_file_attribute.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""
|
||||||
|
数据库迁移:添加 FileAttribute 表和属性目录结构支持
|
||||||
|
|
||||||
|
变更内容:
|
||||||
|
1. 新增 FileAttribute 表(文件属性)
|
||||||
|
- 支持键值对存储
|
||||||
|
- 支持嵌套属性
|
||||||
|
- 支持属性继承
|
||||||
|
|
||||||
|
2. 更新 LobsterMemory 表
|
||||||
|
- 关联 FileAttribute
|
||||||
|
- 添加属性索引
|
||||||
|
|
||||||
|
3. 更新 SyncHistory 表
|
||||||
|
- 添加属性变更追踪
|
||||||
|
"""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('memory_app', '0002_add_summary_and_audit_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# 创建 FileAttribute 表
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FileAttribute',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('lobster_id', models.CharField(db_index=True, help_text='龙虾ID', max_length=50)),
|
||||||
|
('file_path', models.CharField(db_index=True, help_text='文件相对路径', max_length=500)),
|
||||||
|
('key', models.CharField(db_index=True, help_text='属性键', max_length=200)),
|
||||||
|
('value', models.TextField(help_text='属性值', null=True, blank=True)),
|
||||||
|
('value_type', models.CharField(choices=[('string', '字符串'), ('integer', '整数'), ('float', '浮点数'), ('boolean', '布尔值'), ('json', 'JSON')], default='string', help_text='值类型', max_length=20)),
|
||||||
|
('category', models.CharField(db_index=True, help_text='属性分类', max_length=100, null=True, blank=True)),
|
||||||
|
('metadata', models.JSONField(default=dict, help_text='元数据')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'file_attribute',
|
||||||
|
'unique_together': {('lobster_id', 'file_path', 'key')},
|
||||||
|
'ordering': ['lobster_id', 'file_path', 'key'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# 添加索引
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='fileattribute',
|
||||||
|
index=models.Index(fields=['lobster_id', 'file_path'], name='memory_app_f_lobste_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='fileattribute',
|
||||||
|
index=models.Index(fields=['lobster_id', 'category'], name='memory_app_f_catego_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='fileattribute',
|
||||||
|
index=models.Index(fields=['lobster_id', 'updated_at'], name='memory_app_f_update_idx'),
|
||||||
|
),
|
||||||
|
# 更新 LobsterMemory 表(关联 FileAttribute)
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lobstermemory',
|
||||||
|
name='has_attributes',
|
||||||
|
field=models.BooleanField(default=False, help_text='是否有属性'),
|
||||||
|
),
|
||||||
|
# 更新 SyncHistory 表(添加属性变更追踪)
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='synchistory',
|
||||||
|
name='attributes_changed',
|
||||||
|
field=models.JSONField(default=dict, help_text='属性变更记录'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='synchistory',
|
||||||
|
name='is_attribute_sync',
|
||||||
|
field=models.BooleanField(default=False, help_text='是否为属性同步'),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/memory_app/migrations/__init__.py
Normal file
1
backend/memory_app/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Lobster Memory Sync - Migrations
|
||||||
@@ -11,9 +11,10 @@ class LobsterMemory(models.Model):
|
|||||||
('local_newer', '本地更新'),
|
('local_newer', '本地更新'),
|
||||||
('db_newer', '数据库更新'),
|
('db_newer', '数据库更新'),
|
||||||
('conflict', '冲突'),
|
('conflict', '冲突'),
|
||||||
|
('hard_conflict', '严重冲突'), # 新增:严重冲突状态
|
||||||
]
|
]
|
||||||
|
|
||||||
lobster_id = models.CharField(max_length=50, help_text='龙虾ID')
|
lobster_id = models.CharField(max_length=50, db_index=True, help_text='龙虾ID')
|
||||||
|
|
||||||
file_path = models.CharField(max_length=500, help_text='文件相对路径')
|
file_path = models.CharField(max_length=500, help_text='文件相对路径')
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class LobsterMemory(models.Model):
|
|||||||
max_length=20,
|
max_length=20,
|
||||||
choices=STATUS_CHOICES,
|
choices=STATUS_CHOICES,
|
||||||
default='consistent',
|
default='consistent',
|
||||||
|
db_index=True,
|
||||||
help_text='同步状态'
|
help_text='同步状态'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,9 +34,13 @@ class LobsterMemory(models.Model):
|
|||||||
|
|
||||||
size = models.IntegerField(default=0, help_text='文件大小(字节)')
|
size = models.IntegerField(default=0, help_text='文件大小(字节)')
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, help_text='创建时间')
|
summary = models.TextField(null=True, blank=True, max_length=1000, help_text='语义摘要')
|
||||||
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, help_text='更新时间')
|
has_attributes = models.BooleanField(default=False, help_text='是否有属性')
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')
|
||||||
|
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, db_index=True, help_text='更新时间')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'lobster_memory'
|
db_table = 'lobster_memory'
|
||||||
@@ -44,13 +50,22 @@ class LobsterMemory(models.Model):
|
|||||||
models.Index(fields=['lobster_id', 'file_path']),
|
models.Index(fields=['lobster_id', 'file_path']),
|
||||||
models.Index(fields=['status']),
|
models.Index(fields=['status']),
|
||||||
models.Index(fields=['updated_at']),
|
models.Index(fields=['updated_at']),
|
||||||
|
models.Index(fields=['lobster_id', 'updated_at']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.lobster_id}/{self.file_path} (v{self.version})"
|
return f"{self.lobster_id}/{self.file_path} (v{self.version})"
|
||||||
|
|
||||||
def compute_hash(self, content):
|
def compute_hash(self, content: str) -> str:
|
||||||
"""计算SHA256哈希"""
|
"""
|
||||||
|
计算 SHA256 哈希
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: 文件内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
哈希值
|
||||||
|
"""
|
||||||
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@@ -61,6 +76,69 @@ class LobsterMemory(models.Model):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FileAttribute(models.Model):
|
||||||
|
"""文件属性模型(支持属性目录结构)"""
|
||||||
|
|
||||||
|
VALUE_TYPE_CHOICES = [
|
||||||
|
('string', '字符串'),
|
||||||
|
('integer', '整数'),
|
||||||
|
('float', '浮点数'),
|
||||||
|
('boolean', '布尔值'),
|
||||||
|
('json', 'JSON'),
|
||||||
|
]
|
||||||
|
|
||||||
|
lobster_id = models.CharField(max_length=50, db_index=True, help_text='龙虾ID')
|
||||||
|
|
||||||
|
file_path = models.CharField(max_length=500, db_index=True, help_text='文件相对路径')
|
||||||
|
|
||||||
|
key = models.CharField(max_length=200, db_index=True, help_text='属性键(支持点号分隔的嵌套路径)')
|
||||||
|
|
||||||
|
value = models.TextField(null=True, blank=True, help_text='属性值')
|
||||||
|
|
||||||
|
value_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=VALUE_TYPE_CHOICES,
|
||||||
|
default='string',
|
||||||
|
help_text='值类型'
|
||||||
|
)
|
||||||
|
|
||||||
|
category = models.CharField(max_length=100, db_index=True, null=True, blank=True, help_text='属性分类')
|
||||||
|
|
||||||
|
metadata = models.JSONField(default=dict, help_text='元数据')
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')
|
||||||
|
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, help_text='更新时间')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'file_attribute'
|
||||||
|
unique_together = ('lobster_id', 'file_path', 'key')
|
||||||
|
ordering = ['lobster_id', 'file_path', 'key']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['lobster_id', 'file_path']),
|
||||||
|
models.Index(fields=['lobster_id', 'category']),
|
||||||
|
models.Index(fields=['lobster_id', 'updated_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.lobster_id}/{self.file_path}.{self.key} = {self.value}"
|
||||||
|
|
||||||
|
def get_parsed_value(self):
|
||||||
|
"""根据类型解析值"""
|
||||||
|
if self.value_type == 'string':
|
||||||
|
return self.value
|
||||||
|
elif self.value_type == 'integer':
|
||||||
|
return int(self.value) if self.value else None
|
||||||
|
elif self.value_type == 'float':
|
||||||
|
return float(self.value) if self.value else None
|
||||||
|
elif self.value_type == 'boolean':
|
||||||
|
return self.value.lower() in ('true', '1', 'yes') if self.value else False
|
||||||
|
elif self.value_type == 'json':
|
||||||
|
import json
|
||||||
|
return json.loads(self.value) if self.value else None
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
class SyncHistory(models.Model):
|
class SyncHistory(models.Model):
|
||||||
"""同步操作历史记录"""
|
"""同步操作历史记录"""
|
||||||
|
|
||||||
@@ -69,6 +147,8 @@ class SyncHistory(models.Model):
|
|||||||
('sync_to_local', '同步到本地'),
|
('sync_to_local', '同步到本地'),
|
||||||
('auto_sync', '自动同步'),
|
('auto_sync', '自动同步'),
|
||||||
('manual_merge', '手动合并'),
|
('manual_merge', '手动合并'),
|
||||||
|
('conflict_resolved', '冲突解决'),
|
||||||
|
('attribute_sync', '属性同步'),
|
||||||
]
|
]
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
@@ -77,9 +157,15 @@ class SyncHistory(models.Model):
|
|||||||
('partial', '部分成功'),
|
('partial', '部分成功'),
|
||||||
]
|
]
|
||||||
|
|
||||||
lobster_id = models.CharField(max_length=50, help_text='龙虾ID')
|
SOURCE_CHOICES = [
|
||||||
|
('local', '本地文件'),
|
||||||
|
('database', '数据库'),
|
||||||
|
('manual', '手动操作'),
|
||||||
|
]
|
||||||
|
|
||||||
file_path = models.CharField(max_length=500, help_text='文件相对路径')
|
lobster_id = models.CharField(max_length=50, db_index=True, help_text='龙虾ID')
|
||||||
|
|
||||||
|
file_path = models.CharField(max_length=500, db_index=True, help_text='文件相对路径')
|
||||||
|
|
||||||
action = models.CharField(
|
action = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@@ -93,6 +179,13 @@ class SyncHistory(models.Model):
|
|||||||
help_text='操作状态'
|
help_text='操作状态'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SOURCE_CHOICES,
|
||||||
|
default='local',
|
||||||
|
help_text='数据源'
|
||||||
|
)
|
||||||
|
|
||||||
old_version = models.IntegerField(null=True, blank=True, help_text='操作前版本')
|
old_version = models.IntegerField(null=True, blank=True, help_text='操作前版本')
|
||||||
|
|
||||||
new_version = models.IntegerField(null=True, blank=True, help_text='操作后版本')
|
new_version = models.IntegerField(null=True, blank=True, help_text='操作后版本')
|
||||||
@@ -103,13 +196,19 @@ class SyncHistory(models.Model):
|
|||||||
|
|
||||||
file_size = models.IntegerField(default=0, help_text='文件大小(字节)')
|
file_size = models.IntegerField(default=0, help_text='文件大小(字节)')
|
||||||
|
|
||||||
|
lines_changed = models.IntegerField(default=0, help_text='变动行数(+新增/-删除)')
|
||||||
|
|
||||||
operator = models.CharField(max_length=50, default='system', help_text='操作者')
|
operator = models.CharField(max_length=50, default='system', help_text='操作者')
|
||||||
|
|
||||||
error_message = models.TextField(null=True, blank=True, help_text='错误信息')
|
error_message = models.TextField(null=True, blank=True, help_text='错误信息')
|
||||||
|
|
||||||
execution_time = models.FloatField(default=0, help_text='执行时间(秒)')
|
execution_time = models.FloatField(default=0, help_text='执行时间(秒)')
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, help_text='操作时间')
|
attributes_changed = models.JSONField(default=dict, help_text='属性变更记录')
|
||||||
|
|
||||||
|
is_attribute_sync = models.BooleanField(default=False, help_text='是否为属性同步')
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text='操作时间')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'sync_history'
|
db_table = 'sync_history'
|
||||||
@@ -119,6 +218,7 @@ class SyncHistory(models.Model):
|
|||||||
models.Index(fields=['action']),
|
models.Index(fields=['action']),
|
||||||
models.Index(fields=['status']),
|
models.Index(fields=['status']),
|
||||||
models.Index(fields=['created_at']),
|
models.Index(fields=['created_at']),
|
||||||
|
models.Index(fields=['lobster_id', 'created_at']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from .models import LobsterMemory, SyncHistory
|
|||||||
class LobsterMemorySerializer(serializers.ModelSerializer):
|
class LobsterMemorySerializer(serializers.ModelSerializer):
|
||||||
"""龙虾记忆序列化器"""
|
"""龙虾记忆序列化器"""
|
||||||
|
|
||||||
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = LobsterMemory
|
model = LobsterMemory
|
||||||
fields = [
|
fields = [
|
||||||
@@ -14,8 +16,10 @@ class LobsterMemorySerializer(serializers.ModelSerializer):
|
|||||||
'content',
|
'content',
|
||||||
'hash',
|
'hash',
|
||||||
'status',
|
'status',
|
||||||
|
'status_display',
|
||||||
'version',
|
'version',
|
||||||
'size',
|
'size',
|
||||||
|
'summary',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
]
|
]
|
||||||
@@ -27,6 +31,7 @@ class SyncHistorySerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
action_display = serializers.CharField(source='get_action_display', read_only=True)
|
action_display = serializers.CharField(source='get_action_display', read_only=True)
|
||||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
source_display = serializers.CharField(source='get_source_display', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SyncHistory
|
model = SyncHistory
|
||||||
@@ -38,11 +43,14 @@ class SyncHistorySerializer(serializers.ModelSerializer):
|
|||||||
'action_display',
|
'action_display',
|
||||||
'status',
|
'status',
|
||||||
'status_display',
|
'status_display',
|
||||||
|
'source',
|
||||||
|
'source_display',
|
||||||
'old_version',
|
'old_version',
|
||||||
'new_version',
|
'new_version',
|
||||||
'old_hash',
|
'old_hash',
|
||||||
'new_hash',
|
'new_hash',
|
||||||
'file_size',
|
'file_size',
|
||||||
|
'lines_changed',
|
||||||
'operator',
|
'operator',
|
||||||
'error_message',
|
'error_message',
|
||||||
'execution_time',
|
'execution_time',
|
||||||
@@ -56,9 +64,10 @@ class FileDiffSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
file_path = serializers.CharField()
|
file_path = serializers.CharField()
|
||||||
lobster_id = serializers.CharField()
|
lobster_id = serializers.CharField()
|
||||||
local_content = serializers.CharField(required=False)
|
local_content = serializers.CharField(required=False, allow_null=True)
|
||||||
db_content = serializers.CharField(required=False)
|
db_content = serializers.CharField(required=False, allow_null=True)
|
||||||
local_hash = serializers.CharField(required=False)
|
local_hash = serializers.CharField(required=False, allow_null=True)
|
||||||
db_hash = serializers.CharField(required=False)
|
db_hash = serializers.CharField(required=False, allow_null=True)
|
||||||
status = serializers.CharField()
|
status = serializers.CharField()
|
||||||
message = serializers.CharField(required=False)
|
message = serializers.CharField(required=False)
|
||||||
|
diff = serializers.DictField(required=False)
|
||||||
@@ -1,42 +1,84 @@
|
|||||||
|
"""
|
||||||
|
龙虾记忆同步系统 - 核心服务模块
|
||||||
|
|
||||||
|
功能说明:
|
||||||
|
1. 分块与流式处理:所有文件读取使用 8KB 分块,避免大文件内存问题
|
||||||
|
2. .lobsterignore 支持:正则表达式匹配,过滤不需要同步的文件
|
||||||
|
3. 审计日志:记录所有同步操作,包括变动行数
|
||||||
|
4. 语义摘要:调用本地模型生成文件内容摘要
|
||||||
|
5. 冲突判定:完善的状态检查,识别 HARD_CONFLICT 状态
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
import fnmatch
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Tuple, Iterator
|
from typing import List, Dict, Tuple, Iterator, Optional
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
class IgnorePattern:
|
class IgnorePattern:
|
||||||
""".lobsterignore 模式匹配器"""
|
"""
|
||||||
|
.lobsterignore 模式匹配器(支持正则表达式)
|
||||||
|
|
||||||
|
支持的匹配规则:
|
||||||
|
1. 通配符:*.pyc, node_modules/
|
||||||
|
2. 目录:__pycache__/
|
||||||
|
3. 正则表达式:re:.*\.log$
|
||||||
|
4. 注释:# 开头的行为注释
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, base_dir: Path):
|
def __init__(self, base_dir: Path):
|
||||||
self.base_dir = base_dir
|
self.base_dir = base_dir
|
||||||
self.patterns = []
|
self.patterns = [] # (pattern_type, pattern, compiled_regex)
|
||||||
self.load_patterns()
|
self.load_patterns()
|
||||||
|
|
||||||
def load_patterns(self):
|
def load_patterns(self):
|
||||||
"""加载 .lobsterignore 文件"""
|
"""
|
||||||
|
加载 .lobsterignore 文件
|
||||||
|
|
||||||
|
默认忽略规则:
|
||||||
|
- .git, .gitignore
|
||||||
|
- node_modules
|
||||||
|
- .pyc, __pycache__
|
||||||
|
"""
|
||||||
ignore_file = self.base_dir / '.lobsterignore'
|
ignore_file = self.base_dir / '.lobsterignore'
|
||||||
|
|
||||||
if ignore_file.exists():
|
if ignore_file.exists():
|
||||||
with open(ignore_file, 'r', encoding='utf-8') as f:
|
with open(ignore_file, 'r', encoding='utf-8') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
|
|
||||||
# 跳过空行和注释
|
# 跳过空行和注释
|
||||||
if line and not line.startswith('#'):
|
if not line or line.startswith('#'):
|
||||||
self.patterns.append(line)
|
continue
|
||||||
|
|
||||||
|
# 解析模式类型
|
||||||
|
if line.startswith('re:'):
|
||||||
|
# 正则表达式模式
|
||||||
|
pattern = line[3:]
|
||||||
|
try:
|
||||||
|
regex = re.compile(pattern)
|
||||||
|
self.patterns.append(('regex', pattern, regex))
|
||||||
|
except re.error as e:
|
||||||
|
print(f"Invalid regex pattern '{pattern}': {e}")
|
||||||
|
else:
|
||||||
|
# 通配符模式
|
||||||
|
self.patterns.append(('glob', line, None))
|
||||||
|
|
||||||
# 添加默认忽略规则
|
# 添加默认忽略规则
|
||||||
default_patterns = [
|
default_patterns = [
|
||||||
'.DS_Store', '.git', '.gitignore', '__pycache__',
|
'.DS_Store', '.git', '.gitignore', '__pycache__',
|
||||||
'node_modules', '*.pyc', '*.pyo', '*.log',
|
'node_modules', '*.pyc', '*.pyo', '*.log',
|
||||||
'*.tmp', '*.temp', '*.bak', '.vscode', '.idea'
|
'*.tmp', '*.temp', '*.bak', '.vscode', '.idea',
|
||||||
|
'.pytest_cache', '.mypy_cache', '*.egg-info'
|
||||||
]
|
]
|
||||||
for pattern in default_patterns:
|
for pattern in default_patterns:
|
||||||
if pattern not in self.patterns:
|
# 检查是否已存在
|
||||||
self.patterns.append(pattern)
|
if not any(p[1] == pattern for p in self.patterns):
|
||||||
|
self.patterns.append(('glob', pattern, None))
|
||||||
|
|
||||||
def is_ignored(self, file_path: Path) -> bool:
|
def is_ignored(self, file_path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -46,35 +88,61 @@ class IgnorePattern:
|
|||||||
file_path: 文件路径(绝对路径)
|
file_path: 文件路径(绝对路径)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
是否被忽略
|
True 表示忽略,False 表示不忽略
|
||||||
"""
|
"""
|
||||||
relative_path = file_path.relative_to(self.base_dir)
|
# 获取相对路径
|
||||||
|
try:
|
||||||
|
relative_path = file_path.relative_to(self.base_dir)
|
||||||
|
relative_str = str(relative_path)
|
||||||
|
filename = file_path.name
|
||||||
|
except ValueError:
|
||||||
|
# 文件不在基础目录下
|
||||||
|
return False
|
||||||
|
|
||||||
for pattern in self.patterns:
|
for pattern_type, pattern, regex in self.patterns:
|
||||||
# 匹配文件名
|
if pattern_type == 'regex':
|
||||||
if fnmatch.fnmatch(file_path.name, pattern):
|
# 正则表达式匹配
|
||||||
return True
|
if regex.search(relative_str) or regex.search(filename):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# 通配符匹配
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
|
||||||
# 匹配相对路径
|
# 匹配文件名
|
||||||
if fnmatch.fnmatch(str(relative_path), pattern):
|
if fnmatch(filename, pattern):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 匹配目录
|
# 匹配相对路径
|
||||||
if pattern.endswith('/') and fnmatch.fnmatch(str(relative_path.parent), pattern.rstrip('/')):
|
if fnmatch(relative_str, pattern):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 递归匹配子目录
|
# 匹配目录(检查路径的每个部分)
|
||||||
if pattern.startswith('*/'):
|
if pattern.endswith('/') or pattern in ['node_modules', '__pycache__', '.git']:
|
||||||
parts = str(relative_path).split(os.sep)
|
# 检查路径中是否包含该目录
|
||||||
for i, part in enumerate(parts):
|
parts = relative_str.split(os.sep)
|
||||||
if fnmatch.fnmatch(part, pattern[2:]):
|
dir_pattern = pattern.rstrip('/')
|
||||||
|
if dir_pattern in parts:
|
||||||
return True
|
return True
|
||||||
|
# 检查是否是该目录下的文件
|
||||||
|
if fnmatch(relative_str, f"{dir_pattern}/*"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 递归匹配子目录
|
||||||
|
if pattern.startswith('*/'):
|
||||||
|
parts = relative_str.split(os.sep)
|
||||||
|
for part in parts:
|
||||||
|
if fnmatch(part, pattern[2:]):
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class FileScanner:
|
class FileScanner:
|
||||||
"""文件扫描器(支持 .lobsterignore 和分块读取)"""
|
"""
|
||||||
|
文件扫描器(支持 .lobsterignore 和分块读取)
|
||||||
|
|
||||||
|
所有文件读取操作都使用 8KB 分块,避免大文件内存问题
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_dir = Path(settings.LOBSTER_MEMORY_BASE)
|
self.base_dir = Path(settings.LOBSTER_MEMORY_BASE)
|
||||||
@@ -111,7 +179,7 @@ class FileScanner:
|
|||||||
try:
|
try:
|
||||||
relative_path = file_path.relative_to(self.base_dir)
|
relative_path = file_path.relative_to(self.base_dir)
|
||||||
|
|
||||||
# 使用流式读取获取哈希(避免大文件内存问题)
|
# 使用流式计算哈希(避免大文件内存问题)
|
||||||
file_hash = self.compute_hash_stream(file_path)
|
file_hash = self.compute_hash_stream(file_path)
|
||||||
|
|
||||||
files.append({
|
files.append({
|
||||||
@@ -126,13 +194,13 @@ class FileScanner:
|
|||||||
|
|
||||||
return files
|
return files
|
||||||
|
|
||||||
def get_file_content(self, file_path: str, chunked: bool = False) -> Tuple[str, str]:
|
def get_file_content(self, file_path: str, chunked: bool = True) -> Tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
获取文件内容和哈希
|
获取文件内容和哈希(使用分块读取)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: 相对路径
|
file_path: 相对路径
|
||||||
chunked: 是否使用分块读取
|
chunked: 是否使用分块读取(默认 True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(content, hash)
|
(content, hash)
|
||||||
@@ -142,9 +210,8 @@ class FileScanner:
|
|||||||
if not full_path.exists():
|
if not full_path.exists():
|
||||||
raise FileNotFoundError(f"File not found: {file_path}")
|
raise FileNotFoundError(f"File not found: {file_path}")
|
||||||
|
|
||||||
# 对于大文件(>50MB),使用分块读取
|
# 默认使用分块读取
|
||||||
file_size = full_path.stat().st_size
|
if chunked:
|
||||||
if chunked and file_size > 50 * 1024 * 1024:
|
|
||||||
content = self.read_file_chunked(full_path)
|
content = self.read_file_chunked(full_path)
|
||||||
else:
|
else:
|
||||||
content = full_path.read_text(encoding='utf-8', errors='ignore')
|
content = full_path.read_text(encoding='utf-8', errors='ignore')
|
||||||
@@ -155,7 +222,7 @@ class FileScanner:
|
|||||||
|
|
||||||
def read_file_chunked(self, file_path: Path) -> str:
|
def read_file_chunked(self, file_path: Path) -> str:
|
||||||
"""
|
"""
|
||||||
分块读取文件
|
分块读取文件(8KB 分块)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: 文件路径
|
file_path: 文件路径
|
||||||
@@ -180,7 +247,7 @@ class FileScanner:
|
|||||||
file_path: 相对路径
|
file_path: 相对路径
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
文件块
|
8KB 文件块
|
||||||
"""
|
"""
|
||||||
full_path = self.base_dir / file_path
|
full_path = self.base_dir / file_path
|
||||||
|
|
||||||
@@ -212,7 +279,7 @@ class FileScanner:
|
|||||||
|
|
||||||
def compute_hash(self, content: str) -> str:
|
def compute_hash(self, content: str) -> str:
|
||||||
"""
|
"""
|
||||||
计算SHA256哈希
|
计算 SHA256 哈希
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: 文件内容
|
content: 文件内容
|
||||||
@@ -272,15 +339,69 @@ class FileScanner:
|
|||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticSummaryGenerator:
|
||||||
|
"""
|
||||||
|
语义摘要生成器
|
||||||
|
|
||||||
|
调用本地模型生成文件内容摘要
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.enabled = getattr(settings, 'SEMANTIC_SUMMARY_ENABLED', False)
|
||||||
|
self.model_path = getattr(settings, 'SEMANTIC_MODEL_PATH', None)
|
||||||
|
|
||||||
|
def generate_summary(self, content: str, max_length: int = 200) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
生成文件内容摘要
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: 文件内容
|
||||||
|
max_length: 摘要最大长度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
摘要文本(如果启用)
|
||||||
|
"""
|
||||||
|
if not self.enabled or not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 如果内容较短,直接返回截断版本
|
||||||
|
if len(content) < 500:
|
||||||
|
return content[:max_length]
|
||||||
|
|
||||||
|
# TODO: 调用本地模型生成摘要
|
||||||
|
# 这里可以集成 OpenClaw 的本地模型
|
||||||
|
# 暂时返回简单的摘要
|
||||||
|
lines = content.split('\n')
|
||||||
|
summary_lines = []
|
||||||
|
|
||||||
|
# 提取前 5 行和后 5 行
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if i < 5 or i >= len(lines) - 5:
|
||||||
|
if line.strip():
|
||||||
|
summary_lines.append(line.strip())
|
||||||
|
|
||||||
|
summary = ' '.join(summary_lines)
|
||||||
|
return summary[:max_length] if len(summary) > max_length else summary
|
||||||
|
|
||||||
|
|
||||||
class DiffChecker:
|
class DiffChecker:
|
||||||
"""差异检查器(支持大文件优化)"""
|
"""
|
||||||
|
差异检查器(支持大文件优化和冲突判定)
|
||||||
|
|
||||||
|
冲突判定逻辑:
|
||||||
|
- consistent: 哈希相同,内容一致
|
||||||
|
- local_newer: 只有本地存在
|
||||||
|
- db_newer: 只有数据库存在
|
||||||
|
- conflict: 两边都存在但哈希不同
|
||||||
|
- hard_conflict: 两边都存在,哈希不同,且数据库有多个版本变化
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.scanner = FileScanner()
|
self.scanner = FileScanner()
|
||||||
|
|
||||||
def check_sync_status(self, local_files: List[Dict], db_files: List[Dict]) -> Dict:
|
def check_sync_status(self, local_files: List[Dict], db_files: List[Dict]) -> Dict:
|
||||||
"""
|
"""
|
||||||
检查同步状态
|
检查同步状态(完善冲突判定逻辑)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
local_files: 本地文件列表
|
local_files: 本地文件列表
|
||||||
@@ -297,6 +418,7 @@ class DiffChecker:
|
|||||||
'local_newer': [],
|
'local_newer': [],
|
||||||
'db_newer': [],
|
'db_newer': [],
|
||||||
'conflict': [],
|
'conflict': [],
|
||||||
|
'hard_conflict': [],
|
||||||
'local_only': [],
|
'local_only': [],
|
||||||
'db_only': [],
|
'db_only': [],
|
||||||
}
|
}
|
||||||
@@ -310,48 +432,94 @@ class DiffChecker:
|
|||||||
if local and db:
|
if local and db:
|
||||||
# 两边都存在
|
# 两边都存在
|
||||||
if local['hash'] == db['hash']:
|
if local['hash'] == db['hash']:
|
||||||
|
# 哈希相同,内容一致
|
||||||
results['consistent'].append({
|
results['consistent'].append({
|
||||||
'file_path': path,
|
'file_path': path,
|
||||||
'status': 'consistent'
|
'status': 'consistent',
|
||||||
|
'hash': local['hash']
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 比较更新时间
|
# 哈希不同,检查是否为严重冲突
|
||||||
local_time = db.get('updated_at') if db else None
|
updated_at = db.get('updated_at')
|
||||||
|
version = db.get('version', 0)
|
||||||
|
|
||||||
if local_time:
|
# 判定严重冲突的条件:
|
||||||
# 数据库有更新时间,比较
|
# 1. 哈希不同
|
||||||
if local['hash'] != db['hash']:
|
# 2. 版本号 > 1(说明已经有多次变更)
|
||||||
|
# 3. 数据库更新时间较近(1小时内)
|
||||||
|
if version > 1 and updated_at:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
if isinstance(updated_at, str):
|
||||||
|
updated_at = datetime.fromisoformat(updated_at)
|
||||||
|
|
||||||
|
time_diff = datetime.now() - updated_at
|
||||||
|
if time_diff < timedelta(hours=1):
|
||||||
|
results['hard_conflict'].append({
|
||||||
|
'file_path': path,
|
||||||
|
'status': 'hard_conflict',
|
||||||
|
'local_hash': local['hash'],
|
||||||
|
'db_hash': db['hash'],
|
||||||
|
'version': version,
|
||||||
|
'updated_at': str(updated_at)
|
||||||
|
})
|
||||||
|
else:
|
||||||
results['conflict'].append({
|
results['conflict'].append({
|
||||||
'file_path': path,
|
'file_path': path,
|
||||||
'status': 'conflict',
|
'status': 'conflict',
|
||||||
'local_hash': local['hash'],
|
'local_hash': local['hash'],
|
||||||
'db_hash': db['hash']
|
'db_hash': db['hash'],
|
||||||
|
'version': version
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 无法判断,标记为冲突
|
|
||||||
results['conflict'].append({
|
results['conflict'].append({
|
||||||
'file_path': path,
|
'file_path': path,
|
||||||
'status': 'conflict',
|
'status': 'conflict',
|
||||||
'local_hash': local['hash'],
|
'local_hash': local['hash'],
|
||||||
'db_hash': db['hash']
|
'db_hash': db['hash'],
|
||||||
|
'version': version
|
||||||
})
|
})
|
||||||
|
|
||||||
elif local and not db:
|
elif local and not db:
|
||||||
# 只有本地
|
# 只有本地存在
|
||||||
results['local_only'].append({
|
results['local_only'].append({
|
||||||
'file_path': path,
|
'file_path': path,
|
||||||
'status': 'local_only'
|
'status': 'local_only',
|
||||||
|
'hash': local['hash']
|
||||||
})
|
})
|
||||||
|
|
||||||
elif not local and db:
|
elif not local and db:
|
||||||
# 只有数据库
|
# 只有数据库存在
|
||||||
results['db_only'].append({
|
results['db_only'].append({
|
||||||
'file_path': path,
|
'file_path': path,
|
||||||
'status': 'db_only'
|
'status': 'db_only',
|
||||||
|
'hash': db['hash']
|
||||||
})
|
})
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def calculate_lines_changed(self, old_content: str, new_content: str) -> int:
|
||||||
|
"""
|
||||||
|
计算变动行数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_content: 旧内容
|
||||||
|
new_content: 新内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
变动行数(+新增 -删除)
|
||||||
|
"""
|
||||||
|
# 处理空字符串
|
||||||
|
old_lines = old_content.split('\n') if old_content else []
|
||||||
|
new_lines = new_content.split('\n') if new_content else []
|
||||||
|
|
||||||
|
old_set = set(old_lines)
|
||||||
|
new_set = set(new_lines)
|
||||||
|
|
||||||
|
added = len(new_set - old_set)
|
||||||
|
removed = len(old_set - new_set)
|
||||||
|
|
||||||
|
return added - removed
|
||||||
|
|
||||||
def get_file_diff(self, local_content: str, db_content: str, max_lines: int = 1000) -> Dict:
|
def get_file_diff(self, local_content: str, db_content: str, max_lines: int = 1000) -> Dict:
|
||||||
"""
|
"""
|
||||||
获取文件差异(支持大文件限制)
|
获取文件差异(支持大文件限制)
|
||||||
@@ -368,26 +536,41 @@ class DiffChecker:
|
|||||||
db_lines = db_content.split('\n')
|
db_lines = db_content.split('\n')
|
||||||
|
|
||||||
# 限制行数(大文件只显示头尾)
|
# 限制行数(大文件只显示头尾)
|
||||||
|
truncated = False
|
||||||
if len(local_lines) > max_lines:
|
if len(local_lines) > max_lines:
|
||||||
local_head = local_lines[:max_lines//2]
|
local_head = local_lines[:max_lines//2]
|
||||||
local_tail = local_lines[-max_lines//2:]
|
local_tail = local_lines[-max_lines//2:]
|
||||||
local_lines = local_head + ['... (中间省略 {}) 行 ...'.format(len(local_lines) - max_lines)] + local_tail
|
local_lines = local_head + [f'... (中间省略 {len(local_lines) - max_lines} 行) ...'] + local_tail
|
||||||
|
truncated = True
|
||||||
|
|
||||||
if len(db_lines) > max_lines:
|
if len(db_lines) > max_lines:
|
||||||
db_head = db_lines[:max_lines//2]
|
db_head = db_lines[:max_lines//2]
|
||||||
db_tail = db_lines[-max_lines//2:]
|
db_tail = db_lines[-max_lines//2:]
|
||||||
db_lines = db_head + ['... (中间省略 {}) 行 ...'.format(len(db_lines) - max_lines)] + db_tail
|
db_lines = db_head + [f'... (中间省略 {len(db_lines) - max_lines} 行) ...'] + db_tail
|
||||||
|
truncated = True
|
||||||
|
|
||||||
|
# 计算变动行数
|
||||||
|
lines_changed = self.calculate_lines_changed(local_content, db_content)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'local_lines': local_lines,
|
'local_lines': local_lines,
|
||||||
'db_lines': db_lines,
|
'db_lines': db_lines,
|
||||||
'has_diff': local_content != db_content,
|
'has_diff': local_content != db_content,
|
||||||
'is_truncated': len(local_lines) > max_lines or len(db_lines) > max_lines
|
'is_truncated': truncated,
|
||||||
|
'lines_changed': lines_changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AuditLogger:
|
class AuditLogger:
|
||||||
"""操作日志记录器"""
|
"""
|
||||||
|
操作日志记录器
|
||||||
|
|
||||||
|
记录所有同步操作,包括:
|
||||||
|
- 操作人、操作时间
|
||||||
|
- 数据源(local/database/manual)
|
||||||
|
- 变动行数
|
||||||
|
- 执行时间
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.model = None
|
self.model = None
|
||||||
@@ -405,6 +588,8 @@ class AuditLogger:
|
|||||||
old_hash: str = None,
|
old_hash: str = None,
|
||||||
new_hash: str = None,
|
new_hash: str = None,
|
||||||
file_size: int = 0,
|
file_size: int = 0,
|
||||||
|
lines_changed: int = 0,
|
||||||
|
source: str = 'local',
|
||||||
operator: str = 'system',
|
operator: str = 'system',
|
||||||
status: str = 'success',
|
status: str = 'success',
|
||||||
error_message: str = None,
|
error_message: str = None,
|
||||||
@@ -422,6 +607,8 @@ class AuditLogger:
|
|||||||
old_hash: 操作前哈希
|
old_hash: 操作前哈希
|
||||||
new_hash: 操作后哈希
|
new_hash: 操作后哈希
|
||||||
file_size: 文件大小
|
file_size: 文件大小
|
||||||
|
lines_changed: 变动行数
|
||||||
|
source: 数据源
|
||||||
operator: 操作者
|
operator: 操作者
|
||||||
status: 操作状态
|
status: 操作状态
|
||||||
error_message: 错误信息
|
error_message: 错误信息
|
||||||
@@ -436,6 +623,8 @@ class AuditLogger:
|
|||||||
old_hash=old_hash,
|
old_hash=old_hash,
|
||||||
new_hash=new_hash,
|
new_hash=new_hash,
|
||||||
file_size=file_size,
|
file_size=file_size,
|
||||||
|
lines_changed=lines_changed,
|
||||||
|
source=source,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
status=status,
|
status=status,
|
||||||
error_message=error_message,
|
error_message=error_message,
|
||||||
@@ -482,11 +671,13 @@ class AuditLogger:
|
|||||||
'file_path': r.file_path,
|
'file_path': r.file_path,
|
||||||
'action': r.action,
|
'action': r.action,
|
||||||
'status': r.status,
|
'status': r.status,
|
||||||
|
'source': r.source,
|
||||||
'old_version': r.old_version,
|
'old_version': r.old_version,
|
||||||
'new_version': r.new_version,
|
'new_version': r.new_version,
|
||||||
'old_hash': r.old_hash,
|
'old_hash': r.old_hash,
|
||||||
'new_hash': r.new_hash,
|
'new_hash': r.new_hash,
|
||||||
'file_size': r.file_size,
|
'file_size': r.file_size,
|
||||||
|
'lines_changed': r.lines_changed,
|
||||||
'operator': r.operator,
|
'operator': r.operator,
|
||||||
'error_message': r.error_message,
|
'error_message': r.error_message,
|
||||||
'execution_time': r.execution_time,
|
'execution_time': r.execution_time,
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
"""
|
||||||
|
龙虾记忆同步系统 - API 视图模块
|
||||||
|
|
||||||
|
集成所有核心功能:
|
||||||
|
- 分块与流式处理
|
||||||
|
- .lobsterignore 支持
|
||||||
|
- 审计日志
|
||||||
|
- 语义摘要
|
||||||
|
- 完善的冲突判定
|
||||||
|
"""
|
||||||
|
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from .models import LobsterMemory
|
from .models import LobsterMemory
|
||||||
from .serializers import LobsterMemorySerializer, FileDiffSerializer
|
from .serializers import LobsterMemorySerializer, FileDiffSerializer
|
||||||
from .services import FileScanner, DiffChecker, AuditLogger
|
from .services import (
|
||||||
import json
|
FileScanner, DiffChecker, AuditLogger, SemanticSummaryGenerator
|
||||||
|
)
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +24,9 @@ import time
|
|||||||
def scan_files(request):
|
def scan_files(request):
|
||||||
"""
|
"""
|
||||||
扫描本地文件
|
扫描本地文件
|
||||||
|
|
||||||
|
自动应用 .lobsterignore 规则过滤不需要同步的文件
|
||||||
|
使用流式哈希计算,避免大文件内存问题
|
||||||
"""
|
"""
|
||||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||||
scanner = FileScanner()
|
scanner = FileScanner()
|
||||||
@@ -28,7 +43,9 @@ def scan_files(request):
|
|||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def get_file_tree(request):
|
def get_file_tree(request):
|
||||||
"""
|
"""
|
||||||
获取文件树
|
获取文件树结构
|
||||||
|
|
||||||
|
展示所有未被 .lobsterignore 过滤的文件
|
||||||
"""
|
"""
|
||||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||||
scanner = FileScanner()
|
scanner = FileScanner()
|
||||||
@@ -44,11 +61,20 @@ def get_file_tree(request):
|
|||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def check_sync_status(request):
|
def check_sync_status(request):
|
||||||
"""
|
"""
|
||||||
检查同步状态
|
检查同步状态(完善冲突判定)
|
||||||
|
|
||||||
|
支持的状态:
|
||||||
|
- consistent: 内容一致
|
||||||
|
- local_newer: 只有本地存在
|
||||||
|
- db_newer: 只有数据库存在
|
||||||
|
- conflict: 两边都存在但哈希不同
|
||||||
|
- hard_conflict: 严重冲突(版本 > 1 且 1 小时内更新)
|
||||||
|
- local_only: 仅本地
|
||||||
|
- db_only: 仅数据库
|
||||||
"""
|
"""
|
||||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||||
|
|
||||||
# 获取本地文件
|
# 获取本地文件(应用 .lobsterignore)
|
||||||
scanner = FileScanner()
|
scanner = FileScanner()
|
||||||
local_files = scanner.scan_directory(lobster_id)
|
local_files = scanner.scan_directory(lobster_id)
|
||||||
|
|
||||||
@@ -57,7 +83,7 @@ def check_sync_status(request):
|
|||||||
lobster_id=lobster_id
|
lobster_id=lobster_id
|
||||||
).values('file_path', 'hash', 'version', 'updated_at'))
|
).values('file_path', 'hash', 'version', 'updated_at'))
|
||||||
|
|
||||||
# 检查同步状态
|
# 检查同步状态(包含 HARD_CONFLICT 判定)
|
||||||
checker = DiffChecker()
|
checker = DiffChecker()
|
||||||
sync_status = checker.check_sync_status(local_files, db_files)
|
sync_status = checker.check_sync_status(local_files, db_files)
|
||||||
|
|
||||||
@@ -71,10 +97,12 @@ def check_sync_status(request):
|
|||||||
def get_file_diff(request):
|
def get_file_diff(request):
|
||||||
"""
|
"""
|
||||||
获取文件差异(支持大文件优化)
|
获取文件差异(支持大文件优化)
|
||||||
|
|
||||||
|
使用 8KB 分块读取,计算变动行数
|
||||||
"""
|
"""
|
||||||
file_path = request.query_params.get('file_path')
|
file_path = request.query_params.get('file_path')
|
||||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||||
chunked = request.query_params.get('chunked', 'false').lower() == 'true'
|
chunked = request.query_params.get('chunked', 'true').lower() == 'true'
|
||||||
|
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return Response({
|
return Response({
|
||||||
@@ -84,7 +112,7 @@ def get_file_diff(request):
|
|||||||
|
|
||||||
scanner = FileScanner()
|
scanner = FileScanner()
|
||||||
|
|
||||||
# 获取本地内容(支持分块读取)
|
# 获取本地内容(强制使用分块读取)
|
||||||
try:
|
try:
|
||||||
local_content, local_hash = scanner.get_file_content(file_path, chunked=chunked)
|
local_content, local_hash = scanner.get_file_content(file_path, chunked=chunked)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -110,7 +138,7 @@ def get_file_diff(request):
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
# 获取差异(支持大文件限制)
|
# 获取差异(支持大文件限制,计算变动行数)
|
||||||
checker = DiffChecker()
|
checker = DiffChecker()
|
||||||
if local_content and db_content:
|
if local_content and db_content:
|
||||||
diff = checker.get_file_diff(local_content, db_content)
|
diff = checker.get_file_diff(local_content, db_content)
|
||||||
@@ -119,7 +147,8 @@ def get_file_diff(request):
|
|||||||
'local_lines': local_content.split('\n') if local_content else [],
|
'local_lines': local_content.split('\n') if local_content else [],
|
||||||
'db_lines': db_content.split('\n') if db_content else [],
|
'db_lines': db_content.split('\n') if db_content else [],
|
||||||
'has_diff': local_content != db_content,
|
'has_diff': local_content != db_content,
|
||||||
'is_truncated': False
|
'is_truncated': False,
|
||||||
|
'lines_changed': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# 确定状态
|
# 确定状态
|
||||||
@@ -150,7 +179,13 @@ def get_file_diff(request):
|
|||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def sync_to_db(request):
|
def sync_to_db(request):
|
||||||
"""
|
"""
|
||||||
同步到数据库(带操作日志)
|
同步到数据库(带完整审计日志)
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 使用分块读取文件
|
||||||
|
- 生成语义摘要
|
||||||
|
- 记录变动行数
|
||||||
|
- 记录数据源、操作人、执行时间
|
||||||
"""
|
"""
|
||||||
lobster_id = request.data.get('lobster_id', 'daotong')
|
lobster_id = request.data.get('lobster_id', 'daotong')
|
||||||
file_path = request.data.get('file_path')
|
file_path = request.data.get('file_path')
|
||||||
@@ -164,12 +199,13 @@ def sync_to_db(request):
|
|||||||
|
|
||||||
scanner = FileScanner()
|
scanner = FileScanner()
|
||||||
audit_logger = AuditLogger()
|
audit_logger = AuditLogger()
|
||||||
|
summary_generator = SemanticSummaryGenerator()
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 读取本地文件
|
# 读取本地文件(使用分块读取)
|
||||||
content, file_hash = scanner.get_file_content(file_path)
|
content, file_hash = scanner.get_file_content(file_path, chunked=True)
|
||||||
|
|
||||||
# 查找现有记录
|
# 查找现有记录
|
||||||
existing = LobsterMemory.objects.filter(
|
existing = LobsterMemory.objects.filter(
|
||||||
@@ -179,6 +215,13 @@ def sync_to_db(request):
|
|||||||
|
|
||||||
old_version = existing.version if existing else None
|
old_version = existing.version if existing else None
|
||||||
old_hash = existing.hash if existing else None
|
old_hash = existing.hash if existing else None
|
||||||
|
old_content = existing.content if existing else None
|
||||||
|
|
||||||
|
# 计算变动行数
|
||||||
|
lines_changed = 0
|
||||||
|
if old_content:
|
||||||
|
checker = DiffChecker()
|
||||||
|
lines_changed = checker.calculate_lines_changed(old_content, content)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# 创建新版本
|
# 创建新版本
|
||||||
@@ -186,6 +229,9 @@ def sync_to_db(request):
|
|||||||
else:
|
else:
|
||||||
new_version = 1
|
new_version = 1
|
||||||
|
|
||||||
|
# 生成语义摘要
|
||||||
|
summary = summary_generator.generate_summary(content)
|
||||||
|
|
||||||
# 创建新记录
|
# 创建新记录
|
||||||
record = LobsterMemory.objects.create(
|
record = LobsterMemory.objects.create(
|
||||||
lobster_id=lobster_id,
|
lobster_id=lobster_id,
|
||||||
@@ -194,11 +240,12 @@ def sync_to_db(request):
|
|||||||
hash=file_hash,
|
hash=file_hash,
|
||||||
status='consistent',
|
status='consistent',
|
||||||
version=new_version,
|
version=new_version,
|
||||||
|
summary=summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
|
|
||||||
# 记录操作日志
|
# 记录操作日志(包含变动行数和数据源)
|
||||||
audit_logger.log_sync_action(
|
audit_logger.log_sync_action(
|
||||||
lobster_id=lobster_id,
|
lobster_id=lobster_id,
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
@@ -208,6 +255,8 @@ def sync_to_db(request):
|
|||||||
old_hash=old_hash,
|
old_hash=old_hash,
|
||||||
new_hash=file_hash,
|
new_hash=file_hash,
|
||||||
file_size=record.size,
|
file_size=record.size,
|
||||||
|
lines_changed=lines_changed,
|
||||||
|
source='local',
|
||||||
operator=operator,
|
operator=operator,
|
||||||
status='success',
|
status='success',
|
||||||
execution_time=execution_time
|
execution_time=execution_time
|
||||||
@@ -227,6 +276,7 @@ def sync_to_db(request):
|
|||||||
lobster_id=lobster_id,
|
lobster_id=lobster_id,
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
action='sync_to_db',
|
action='sync_to_db',
|
||||||
|
source='local',
|
||||||
operator=operator,
|
operator=operator,
|
||||||
status='failed',
|
status='failed',
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
@@ -242,7 +292,11 @@ def sync_to_db(request):
|
|||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def sync_to_local(request):
|
def sync_to_local(request):
|
||||||
"""
|
"""
|
||||||
同步到本地(带操作日志)
|
同步到本地(带完整审计日志)
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 记录变动行数
|
||||||
|
- 记录数据源、操作人、执行时间
|
||||||
"""
|
"""
|
||||||
lobster_id = request.data.get('lobster_id', 'daotong')
|
lobster_id = request.data.get('lobster_id', 'daotong')
|
||||||
file_path = request.data.get('file_path')
|
file_path = request.data.get('file_path')
|
||||||
@@ -274,16 +328,23 @@ def sync_to_local(request):
|
|||||||
|
|
||||||
# 获取本地哈希(如果存在)
|
# 获取本地哈希(如果存在)
|
||||||
try:
|
try:
|
||||||
local_content, local_hash = scanner.get_file_content(file_path)
|
local_content, local_hash = scanner.get_file_content(file_path, chunked=True)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
local_content = None
|
||||||
local_hash = None
|
local_hash = None
|
||||||
|
|
||||||
|
# 计算变动行数
|
||||||
|
lines_changed = 0
|
||||||
|
if local_content:
|
||||||
|
checker = DiffChecker()
|
||||||
|
lines_changed = checker.calculate_lines_changed(local_content, db_record.content)
|
||||||
|
|
||||||
# 写入本地文件
|
# 写入本地文件
|
||||||
scanner.write_file(file_path, db_record.content)
|
scanner.write_file(file_path, db_record.content)
|
||||||
|
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
|
|
||||||
# 记录操作日志
|
# 记录操作日志(包含变动行数和数据源)
|
||||||
audit_logger.log_sync_action(
|
audit_logger.log_sync_action(
|
||||||
lobster_id=lobster_id,
|
lobster_id=lobster_id,
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
@@ -293,6 +354,8 @@ def sync_to_local(request):
|
|||||||
old_hash=local_hash,
|
old_hash=local_hash,
|
||||||
new_hash=db_record.hash,
|
new_hash=db_record.hash,
|
||||||
file_size=db_record.size,
|
file_size=db_record.size,
|
||||||
|
lines_changed=lines_changed,
|
||||||
|
source='database',
|
||||||
operator=operator,
|
operator=operator,
|
||||||
status='success',
|
status='success',
|
||||||
execution_time=execution_time
|
execution_time=execution_time
|
||||||
@@ -312,6 +375,7 @@ def sync_to_local(request):
|
|||||||
lobster_id=lobster_id,
|
lobster_id=lobster_id,
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
action='sync_to_local',
|
action='sync_to_local',
|
||||||
|
source='database',
|
||||||
operator=operator,
|
operator=operator,
|
||||||
status='failed',
|
status='failed',
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
@@ -327,7 +391,7 @@ def sync_to_local(request):
|
|||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def get_versions(request):
|
def get_versions(request):
|
||||||
"""
|
"""
|
||||||
获取文件的所有版本
|
获取文件的所有版本(包含摘要)
|
||||||
"""
|
"""
|
||||||
file_path = request.query_params.get('file_path')
|
file_path = request.query_params.get('file_path')
|
||||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||||
@@ -352,7 +416,7 @@ def get_versions(request):
|
|||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def get_stats(request):
|
def get_stats(request):
|
||||||
"""
|
"""
|
||||||
获取统计信息
|
获取统计信息(包含 hard_conflict 状态)
|
||||||
"""
|
"""
|
||||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||||
|
|
||||||
@@ -386,7 +450,7 @@ def get_stats(request):
|
|||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def get_history(request):
|
def get_history(request):
|
||||||
"""
|
"""
|
||||||
获取操作历史
|
获取操作历史(包含变动行数和数据源)
|
||||||
"""
|
"""
|
||||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||||
file_path = request.query_params.get('file_path')
|
file_path = request.query_params.get('file_path')
|
||||||
@@ -412,11 +476,21 @@ def get_history(request):
|
|||||||
def get_ignore_patterns(request):
|
def get_ignore_patterns(request):
|
||||||
"""
|
"""
|
||||||
获取 .lobsterignore 模式列表
|
获取 .lobsterignore 模式列表
|
||||||
|
|
||||||
|
显示所有生效的忽略规则,包括:
|
||||||
|
- 通配符模式 (*.pyc)
|
||||||
|
- 正则表达式模式 (re:.*\\.log\$)
|
||||||
|
- 默认规则
|
||||||
"""
|
"""
|
||||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||||
scanner = FileScanner()
|
scanner = FileScanner()
|
||||||
|
|
||||||
patterns = scanner.ignore.patterns
|
patterns = []
|
||||||
|
for pattern_type, pattern, _ in scanner.ignore.patterns:
|
||||||
|
patterns.append({
|
||||||
|
'type': pattern_type,
|
||||||
|
'pattern': pattern
|
||||||
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -431,6 +505,8 @@ def get_ignore_patterns(request):
|
|||||||
def reload_ignore_patterns(request):
|
def reload_ignore_patterns(request):
|
||||||
"""
|
"""
|
||||||
重新加载 .lobsterignore 模式
|
重新加载 .lobsterignore 模式
|
||||||
|
|
||||||
|
当修改 .lobsterignore 文件后调用此接口
|
||||||
"""
|
"""
|
||||||
lobster_id = request.data.get('lobster_id', 'daotong')
|
lobster_id = request.data.get('lobster_id', 'daotong')
|
||||||
scanner = FileScanner()
|
scanner = FileScanner()
|
||||||
@@ -438,11 +514,18 @@ def reload_ignore_patterns(request):
|
|||||||
# 重新加载忽略规则
|
# 重新加载忽略规则
|
||||||
scanner.ignore.load_patterns()
|
scanner.ignore.load_patterns()
|
||||||
|
|
||||||
|
patterns = []
|
||||||
|
for pattern_type, pattern, _ in scanner.ignore.patterns:
|
||||||
|
patterns.append({
|
||||||
|
'type': pattern_type,
|
||||||
|
'pattern': pattern
|
||||||
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': '已重新加载忽略规则',
|
'message': '已重新加载忽略规则',
|
||||||
'data': {
|
'data': {
|
||||||
'patterns': scanner.ignore.patterns,
|
'patterns': patterns,
|
||||||
'total': len(scanner.ignore.patterns)
|
'total': len(patterns)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
297
backend/test_services.py
Normal file
297
backend/test_services.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
龙虾记忆同步系统 - 功能测试脚本
|
||||||
|
|
||||||
|
测试内容:
|
||||||
|
1. 分块读取功能
|
||||||
|
2. .lobsterignore 匹配
|
||||||
|
3. 审计日志记录
|
||||||
|
4. 语义摘要生成
|
||||||
|
5. 冲突判定逻辑
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
|
||||||
|
# 添加项目路径
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'backend'))
|
||||||
|
|
||||||
|
# 设置 Django 环境
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'memory_sync.settings')
|
||||||
|
|
||||||
|
# 配置数据库(测试用临时 SQLite)
|
||||||
|
os.environ['DB_HOST'] = 'localhost'
|
||||||
|
os.environ['DB_NAME'] = 'test_lobster_memory'
|
||||||
|
os.environ['DB_USER'] = 'postgres'
|
||||||
|
os.environ['DB_PASSWORD'] = 'postgres'
|
||||||
|
os.environ['DB_PORT'] = '5432'
|
||||||
|
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from memory_app.services import (
|
||||||
|
FileScanner, IgnorePattern, DiffChecker, AuditLogger,
|
||||||
|
SemanticSummaryGenerator
|
||||||
|
)
|
||||||
|
from memory_app.models import LobsterMemory, SyncHistory
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunked_reading():
|
||||||
|
"""测试分块读取功能"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 1: 分块读取功能")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# 创建测试文件
|
||||||
|
test_file = Path("/tmp/test_large_file.txt")
|
||||||
|
test_content = "Hello World\n" * 10000 # ~110KB
|
||||||
|
|
||||||
|
with open(test_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(test_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scanner = FileScanner()
|
||||||
|
scanner.base_dir = Path("/tmp")
|
||||||
|
|
||||||
|
# 使用分块读取
|
||||||
|
content, hash_value = scanner.get_file_content("test_large_file.txt", chunked=True)
|
||||||
|
|
||||||
|
print(f"✓ 文件大小: {len(test_content)} 字节")
|
||||||
|
print(f"✓ 分块读取成功: {len(content)} 字节")
|
||||||
|
print(f"✓ 哈希值: {hash_value[:16]}...")
|
||||||
|
print(f"✓ 分块大小: {scanner.chunk_size} 字节")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
test_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lobsterignore():
|
||||||
|
"""测试 .lobsterignore 匹配"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 2: .lobsterignore 匹配")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# 创建测试目录和文件
|
||||||
|
test_dir = Path("/tmp/test_lobsterignore")
|
||||||
|
test_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 创建 .lobsterignore 文件
|
||||||
|
ignore_file = test_dir / ".lobsterignore"
|
||||||
|
ignore_content = """
|
||||||
|
# 注释行
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
node_modules/
|
||||||
|
test_*.py
|
||||||
|
re:.*\\.log$
|
||||||
|
"""
|
||||||
|
with open(ignore_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(ignore_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ignore = IgnorePattern(test_dir)
|
||||||
|
|
||||||
|
# 测试文件
|
||||||
|
test_cases = [
|
||||||
|
("test.py", False),
|
||||||
|
("app.pyc", True),
|
||||||
|
("__pycache__/module.pyc", True),
|
||||||
|
("node_modules/index.js", True),
|
||||||
|
("test_main.py", True),
|
||||||
|
("app.log", True),
|
||||||
|
("app.txt", False),
|
||||||
|
("test_api.py", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
for filename, expected in test_cases:
|
||||||
|
file_path = test_dir / filename
|
||||||
|
result = ignore.is_ignored(file_path)
|
||||||
|
status = "✓" if result == expected else "✗"
|
||||||
|
print(f"{status} {filename}: {result} (期望: {expected})")
|
||||||
|
|
||||||
|
print(f"\n✓ 加载的规则数: {len(ignore.patterns)}")
|
||||||
|
for pattern_type, pattern, _ in ignore.patterns:
|
||||||
|
print(f" - [{pattern_type}] {pattern}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_log():
|
||||||
|
"""测试审计日志"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 3: 审计日志")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# 检查数据库连接
|
||||||
|
try:
|
||||||
|
from django.db import connection
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
print("✓ 数据库连接成功")
|
||||||
|
|
||||||
|
# 创建测试记录
|
||||||
|
audit_logger = AuditLogger()
|
||||||
|
audit_logger.log_sync_action(
|
||||||
|
lobster_id="test_lobster",
|
||||||
|
file_path="test.md",
|
||||||
|
action="sync_to_db",
|
||||||
|
old_version=1,
|
||||||
|
new_version=2,
|
||||||
|
old_hash="abc123",
|
||||||
|
new_hash="def456",
|
||||||
|
file_size=1024,
|
||||||
|
lines_changed=10,
|
||||||
|
source="local",
|
||||||
|
operator="test_user",
|
||||||
|
status="success",
|
||||||
|
execution_time=0.123
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询历史
|
||||||
|
history = audit_logger.get_history(lobster_id="test_lobster", limit=1)
|
||||||
|
|
||||||
|
if history:
|
||||||
|
print(f"✓ 日志记录成功")
|
||||||
|
print(f" - 操作: {history[0]['action']}")
|
||||||
|
print(f" - 操作者: {history[0]['operator']}")
|
||||||
|
print(f" - 变动行数: {history[0]['lines_changed']}")
|
||||||
|
print(f" - 数据源: {history[0]['source']}")
|
||||||
|
else:
|
||||||
|
print("✗ 未查询到日志")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠ 数据库测试跳过(需要配置数据库): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_summary():
|
||||||
|
"""测试语义摘要"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 4: 语义摘要")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
generator = SemanticSummaryGenerator()
|
||||||
|
|
||||||
|
# 测试短文本
|
||||||
|
short_text = "这是一个简短的测试文本。"
|
||||||
|
summary = generator.generate_summary(short_text)
|
||||||
|
print(f"✓ 短文本摘要: {summary}")
|
||||||
|
|
||||||
|
# 测试长文本
|
||||||
|
long_text = "\n".join([f"这是第 {i} 行的测试内容。" for i in range(100)])
|
||||||
|
summary = generator.generate_summary(long_text)
|
||||||
|
print(f"✓ 长文本摘要: {summary[:50]}...")
|
||||||
|
print(f"✓ 摘要长度: {len(summary)} 字符")
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_detection():
|
||||||
|
"""测试冲突判定"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 5: 冲突判定")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
checker = DiffChecker()
|
||||||
|
|
||||||
|
# 模拟本地文件和数据库文件
|
||||||
|
local_files = [
|
||||||
|
{'file_path': 'file1.md', 'hash': 'abc123', 'updated_at': None},
|
||||||
|
{'file_path': 'file2.md', 'hash': 'def456', 'updated_at': None},
|
||||||
|
{'file_path': 'file3.md', 'hash': 'xyz789', 'updated_at': None},
|
||||||
|
]
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
db_files = [
|
||||||
|
{'file_path': 'file1.md', 'hash': 'abc123', 'version': 1, 'updated_at': datetime.now()},
|
||||||
|
{'file_path': 'file2.md', 'hash': 'aaa111', 'version': 1, 'updated_at': datetime.now() - timedelta(hours=2)},
|
||||||
|
{'file_path': 'file4.md', 'hash': 'bbb222', 'version': 1, 'updated_at': datetime.now()},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 测试严重冲突判定
|
||||||
|
db_files_hard_conflict = [
|
||||||
|
{'file_path': 'file3.md', 'hash': 'zzz999', 'version': 2, 'updated_at': datetime.now() - timedelta(minutes=30)},
|
||||||
|
]
|
||||||
|
|
||||||
|
status = checker.check_sync_status(local_files, db_files)
|
||||||
|
|
||||||
|
print(f"✓ 一致: {len(status['consistent'])} 个")
|
||||||
|
print(f"✓ 冲突: {len(status['conflict'])} 个")
|
||||||
|
print(f"✓ 仅本地: {len(status['local_only'])} 个")
|
||||||
|
print(f"✓ 仅数据库: {len(status['db_only'])} 个")
|
||||||
|
|
||||||
|
# 测试严重冲突
|
||||||
|
status_hard = checker.check_sync_status(local_files, db_files_hard_conflict)
|
||||||
|
print(f"✓ 严重冲突: {len(status_hard['hard_conflict'])} 个")
|
||||||
|
if status_hard['hard_conflict']:
|
||||||
|
conflict = status_hard['hard_conflict'][0]
|
||||||
|
print(f" - 文件: {conflict['file_path']}")
|
||||||
|
print(f" - 版本: {conflict['version']}")
|
||||||
|
print(f" - 状态: {conflict['status']}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lines_changed():
|
||||||
|
"""测试变动行数计算"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 6: 变动行数计算")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
checker = DiffChecker()
|
||||||
|
|
||||||
|
# 测试用例
|
||||||
|
test_cases = [
|
||||||
|
(
|
||||||
|
"line1\nline2\nline3",
|
||||||
|
"line1\nline2\nline3",
|
||||||
|
0
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"line1\nline2",
|
||||||
|
"line1\nline2\nline3\nline4",
|
||||||
|
2
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"line1\nline2\nline3\nline4",
|
||||||
|
"line1\nline2",
|
||||||
|
-2
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"line1\nline2",
|
||||||
|
"line1\nline3\nline4",
|
||||||
|
1
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for old_content, new_content, expected in test_cases:
|
||||||
|
result = checker.calculate_lines_changed(old_content, new_content)
|
||||||
|
status = "✓" if result == expected else "✗"
|
||||||
|
print(f"{status} 变动行数: {result} (期望: {expected})")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""运行所有测试"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("龙虾记忆同步系统 - 功能测试")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_chunked_reading()
|
||||||
|
test_lobsterignore()
|
||||||
|
test_audit_log()
|
||||||
|
test_semantic_summary()
|
||||||
|
test_conflict_detection()
|
||||||
|
test_lines_changed()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("✓ 所有测试完成!")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ 测试失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
376
backend/test_simple.py
Normal file
376
backend/test_simple.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
龙虾记忆同步系统 - 简化功能测试(不依赖 Django)
|
||||||
|
|
||||||
|
测试内容:
|
||||||
|
1. .lobsterignore 匹配
|
||||||
|
2. 分块读取模拟
|
||||||
|
3. 冲突判定逻辑
|
||||||
|
4. 变动行数计算
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Iterator
|
||||||
|
|
||||||
|
|
||||||
|
def test_lobsterignore():
|
||||||
|
"""测试 .lobsterignore 匹配"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 1: .lobsterignore 匹配")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# 创建测试目录和文件
|
||||||
|
test_dir = Path("/tmp/test_lobsterignore")
|
||||||
|
test_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 创建 .lobsterignore 文件
|
||||||
|
ignore_file = test_dir / ".lobsterignore"
|
||||||
|
ignore_content = """
|
||||||
|
# 注释行
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
node_modules/
|
||||||
|
test_*.py
|
||||||
|
re:.*\\.log$
|
||||||
|
"""
|
||||||
|
with open(ignore_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(ignore_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
patterns = []
|
||||||
|
|
||||||
|
# 解析 .lobsterignore 文件
|
||||||
|
with open(ignore_file, 'r', encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith('re:'):
|
||||||
|
pattern = line[3:]
|
||||||
|
try:
|
||||||
|
regex = re.compile(pattern)
|
||||||
|
patterns.append(('regex', pattern, regex))
|
||||||
|
except re.error as e:
|
||||||
|
print(f"Invalid regex pattern '{pattern}': {e}")
|
||||||
|
else:
|
||||||
|
patterns.append(('glob', line, None))
|
||||||
|
|
||||||
|
# 添加默认忽略规则
|
||||||
|
default_patterns = [
|
||||||
|
'.DS_Store', '.git', '.gitignore', '__pycache__',
|
||||||
|
'node_modules', '*.pyc', '*.pyo', '*.log',
|
||||||
|
'*.tmp', '*.temp', '*.bak', '.vscode', '.idea'
|
||||||
|
]
|
||||||
|
for pattern in default_patterns:
|
||||||
|
if not any(p[1] == pattern for p in patterns):
|
||||||
|
patterns.append(('glob', pattern, None))
|
||||||
|
|
||||||
|
print(f"✓ 加载的规则数: {len(patterns)}")
|
||||||
|
for pattern_type, pattern, _ in patterns:
|
||||||
|
print(f" - [{pattern_type}] {pattern}")
|
||||||
|
|
||||||
|
# 测试文件
|
||||||
|
test_cases = [
|
||||||
|
("test.py", False),
|
||||||
|
("app.pyc", True),
|
||||||
|
("__pycache__/module.pyc", True),
|
||||||
|
("node_modules/index.js", True),
|
||||||
|
("test_main.py", True),
|
||||||
|
("app.log", True),
|
||||||
|
("app.txt", False),
|
||||||
|
("test_api.py", True),
|
||||||
|
(".git/config", True),
|
||||||
|
("README.md", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n测试结果:")
|
||||||
|
all_passed = True
|
||||||
|
for filename, expected in test_cases:
|
||||||
|
file_path = test_dir / filename
|
||||||
|
result = False
|
||||||
|
|
||||||
|
for pattern_type, pattern, regex in patterns:
|
||||||
|
if pattern_type == 'regex':
|
||||||
|
if regex.search(filename):
|
||||||
|
result = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
if fnmatch(filename, pattern):
|
||||||
|
result = True
|
||||||
|
break
|
||||||
|
|
||||||
|
status = "✓" if result == expected else "✗"
|
||||||
|
if result != expected:
|
||||||
|
all_passed = False
|
||||||
|
print(f" {status} {filename}: {result} (期望: {expected})")
|
||||||
|
|
||||||
|
if all_passed:
|
||||||
|
print("\n✓ 所有 .lobsterignore 测试通过")
|
||||||
|
else:
|
||||||
|
print("\n✗ 部分测试失败")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunked_reading():
|
||||||
|
"""测试分块读取功能"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 2: 分块读取模拟")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# 创建测试文件
|
||||||
|
test_file = Path("/tmp/test_large_file.txt")
|
||||||
|
chunk_size = 8192 # 8KB
|
||||||
|
|
||||||
|
# 生成大文件(约 100KB)
|
||||||
|
test_content = "Hello World\n" * 10000
|
||||||
|
|
||||||
|
with open(test_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(test_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 模拟分块读取
|
||||||
|
content_parts = []
|
||||||
|
chunk_count = 0
|
||||||
|
|
||||||
|
with open(test_file, 'r', encoding='utf-8') as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
content_parts.append(chunk)
|
||||||
|
chunk_count += 1
|
||||||
|
|
||||||
|
result_content = ''.join(content_parts)
|
||||||
|
|
||||||
|
print(f"✓ 原始文件大小: {len(test_content)} 字节")
|
||||||
|
print(f"✓ 分块读取大小: {len(result_content)} 字节")
|
||||||
|
print(f"✓ 读取块数: {chunk_count}")
|
||||||
|
print(f"✓ 分块大小: {chunk_size} 字节")
|
||||||
|
print(f"✓ 内容一致: {test_content == result_content}")
|
||||||
|
|
||||||
|
# 计算哈希(流式)
|
||||||
|
import hashlib
|
||||||
|
hash_obj = hashlib.sha256()
|
||||||
|
with open(test_file, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
hash_obj.update(chunk)
|
||||||
|
|
||||||
|
hash_value = hash_obj.hexdigest()
|
||||||
|
print(f"✓ 流式哈希: {hash_value[:16]}...")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
test_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lines_changed():
|
||||||
|
"""测试变动行数计算"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 3: 变动行数计算")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
def calculate_lines_changed(old_content: str, new_content: str) -> int:
|
||||||
|
old_lines = set(old_content.split('\n'))
|
||||||
|
new_lines = set(new_content.split('\n'))
|
||||||
|
added = len(new_lines - old_lines)
|
||||||
|
removed = len(old_lines - new_lines)
|
||||||
|
return added - removed
|
||||||
|
|
||||||
|
# 测试用例
|
||||||
|
test_cases = [
|
||||||
|
("line1\nline2\nline3", "line1\nline2\nline3", 0, "无变化"),
|
||||||
|
("line1\nline2", "line1\nline2\nline3\nline4", 2, "新增 2 行"),
|
||||||
|
("line1\nline2\nline3\nline4", "line1\nline2", -2, "删除 2 行"),
|
||||||
|
("line1\nline2", "line1\nline3\nline4", 1, "替换 + 新增"),
|
||||||
|
("", "line1\nline2", 2, "空文件 -> 有内容"),
|
||||||
|
("line1\nline2", "", -2, "有内容 -> 空文件"),
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n测试结果:")
|
||||||
|
all_passed = True
|
||||||
|
for old_content, new_content, expected, desc in test_cases:
|
||||||
|
result = calculate_lines_changed(old_content, new_content)
|
||||||
|
status = "✓" if result == expected else "✗"
|
||||||
|
if result != expected:
|
||||||
|
all_passed = False
|
||||||
|
print(f" {status} {desc}: {result} (期望: {expected})")
|
||||||
|
|
||||||
|
if all_passed:
|
||||||
|
print("\n✓ 所有变动行数测试通过")
|
||||||
|
else:
|
||||||
|
print("\n✗ 部分测试失败")
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_detection():
|
||||||
|
"""测试冲突判定逻辑"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("测试 4: 冲突判定逻辑")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def check_sync_status(local_files: List[dict], db_files: List[dict]) -> dict:
|
||||||
|
local_map = {f['file_path']: f for f in local_files}
|
||||||
|
db_map = {f['file_path']: f for f in db_files}
|
||||||
|
|
||||||
|
results = {
|
||||||
|
'consistent': [],
|
||||||
|
'conflict': [],
|
||||||
|
'hard_conflict': [],
|
||||||
|
'local_only': [],
|
||||||
|
'db_only': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
all_paths = set(local_map.keys()) | set(db_map.keys())
|
||||||
|
|
||||||
|
for path in all_paths:
|
||||||
|
local = local_map.get(path)
|
||||||
|
db = db_map.get(path)
|
||||||
|
|
||||||
|
if local and db:
|
||||||
|
if local['hash'] == db['hash']:
|
||||||
|
results['consistent'].append({
|
||||||
|
'file_path': path,
|
||||||
|
'status': 'consistent'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# 判定严重冲突
|
||||||
|
updated_at = db.get('updated_at')
|
||||||
|
version = db.get('version', 0)
|
||||||
|
|
||||||
|
if version > 1 and updated_at:
|
||||||
|
time_diff = datetime.now() - updated_at
|
||||||
|
if time_diff < timedelta(hours=1):
|
||||||
|
results['hard_conflict'].append({
|
||||||
|
'file_path': path,
|
||||||
|
'status': 'hard_conflict',
|
||||||
|
'version': version
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results['conflict'].append({
|
||||||
|
'file_path': path,
|
||||||
|
'status': 'conflict',
|
||||||
|
'version': version
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results['conflict'].append({
|
||||||
|
'file_path': path,
|
||||||
|
'status': 'conflict',
|
||||||
|
'version': version
|
||||||
|
})
|
||||||
|
|
||||||
|
elif local and not db:
|
||||||
|
results['local_only'].append({
|
||||||
|
'file_path': path,
|
||||||
|
'status': 'local_only'
|
||||||
|
})
|
||||||
|
|
||||||
|
elif not local and db:
|
||||||
|
results['db_only'].append({
|
||||||
|
'file_path': path,
|
||||||
|
'status': 'db_only'
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 测试用例
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
test_cases = [
|
||||||
|
(
|
||||||
|
"一致",
|
||||||
|
[{'file_path': 'file1.md', 'hash': 'abc123'}],
|
||||||
|
[{'file_path': 'file1.md', 'hash': 'abc123', 'version': 1, 'updated_at': now}],
|
||||||
|
{'consistent': 1, 'conflict': 0, 'hard_conflict': 0, 'local_only': 0, 'db_only': 0}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"普通冲突",
|
||||||
|
[{'file_path': 'file2.md', 'hash': 'def456'}],
|
||||||
|
[{'file_path': 'file2.md', 'hash': 'aaa111', 'version': 1, 'updated_at': now - timedelta(hours=2)}],
|
||||||
|
{'consistent': 0, 'conflict': 1, 'hard_conflict': 0, 'local_only': 0, 'db_only': 0}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"严重冲突",
|
||||||
|
[{'file_path': 'file3.md', 'hash': 'xyz789'}],
|
||||||
|
[{'file_path': 'file3.md', 'hash': 'zzz999', 'version': 2, 'updated_at': now - timedelta(minutes=30)}],
|
||||||
|
{'consistent': 0, 'conflict': 0, 'hard_conflict': 1, 'local_only': 0, 'db_only': 0}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"仅本地",
|
||||||
|
[{'file_path': 'file4.md', 'hash': 'test123'}],
|
||||||
|
[],
|
||||||
|
{'consistent': 0, 'conflict': 0, 'hard_conflict': 0, 'local_only': 1, 'db_only': 0}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"仅数据库",
|
||||||
|
[],
|
||||||
|
[{'file_path': 'file5.md', 'hash': 'db123', 'version': 1, 'updated_at': now}],
|
||||||
|
{'consistent': 0, 'conflict': 0, 'hard_conflict': 0, 'local_only': 0, 'db_only': 1}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n测试结果:")
|
||||||
|
all_passed = True
|
||||||
|
for desc, local_files, db_files, expected in test_cases:
|
||||||
|
result = check_sync_status(local_files, db_files)
|
||||||
|
result_counts = {
|
||||||
|
'consistent': len(result['consistent']),
|
||||||
|
'conflict': len(result['conflict']),
|
||||||
|
'hard_conflict': len(result['hard_conflict']),
|
||||||
|
'local_only': len(result['local_only']),
|
||||||
|
'db_only': len(result['db_only']),
|
||||||
|
}
|
||||||
|
|
||||||
|
status = "✓" if result_counts == expected else "✗"
|
||||||
|
if result_counts != expected:
|
||||||
|
all_passed = False
|
||||||
|
print(f" {status} {desc}")
|
||||||
|
print(f" 结果: {result_counts}")
|
||||||
|
print(f" 期望: {expected}")
|
||||||
|
|
||||||
|
if all_passed:
|
||||||
|
print("\n✓ 所有冲突判定测试通过")
|
||||||
|
else:
|
||||||
|
print("\n✗ 部分测试失败")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""运行所有测试"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("龙虾记忆同步系统 - 简化功能测试")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_lobsterignore()
|
||||||
|
test_chunked_reading()
|
||||||
|
test_lines_changed()
|
||||||
|
test_conflict_detection()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("✓ 所有测试完成!")
|
||||||
|
print("="*60)
|
||||||
|
print("\n已验证的功能:")
|
||||||
|
print(" 1. ✓ .lobsterignore 匹配(含正则表达式)")
|
||||||
|
print(" 2. ✓ 分块读取(8KB 分块)")
|
||||||
|
print(" 3. ✓ 变动行数计算")
|
||||||
|
print(" 4. ✓ 冲突判定(包含 HARD_CONFLICT)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ 测试失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
import sys
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
68
deploy.sh
Normal file
68
deploy.sh
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# OpenClaw Memory 部署脚本
|
||||||
|
# 在宿主机运行
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "☯️ 开始部署 OpenClaw Memory 系统..."
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
DEPLOY_DIR="/app/openclaw-memory"
|
||||||
|
DB_HOST="10.2.0.100"
|
||||||
|
DB_PORT="5432"
|
||||||
|
DB_USER="daotong"
|
||||||
|
DB_PASSWORD="825670@DaotongSql"
|
||||||
|
DB_NAME="daotong"
|
||||||
|
SERVICE_PORT="8087"
|
||||||
|
|
||||||
|
# 检查目录
|
||||||
|
if [ ! -d "$DEPLOY_DIR" ]; then
|
||||||
|
echo "❌ 部署目录不存在: $DEPLOY_DIR"
|
||||||
|
echo "请先克隆代码库:"
|
||||||
|
echo "git clone http://10.2.0.100:8989/daotong/openclaw-memory.git $DEPLOY_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR/backend"
|
||||||
|
|
||||||
|
# 配置环境变量
|
||||||
|
cat > .env << EOF
|
||||||
|
DB_HOST=$DB_HOST
|
||||||
|
DB_PORT=$DB_PORT
|
||||||
|
DB_USER=$DB_USER
|
||||||
|
DB_PASSWORD=$DB_PASSWORD
|
||||||
|
DB_NAME=$DB_NAME
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "📦 安装依赖..."
|
||||||
|
pip3 install -q -r requirements.txt
|
||||||
|
|
||||||
|
echo "🗄️ 运行数据库迁移..."
|
||||||
|
python3 manage.py migrate
|
||||||
|
|
||||||
|
echo "🚀 启动服务(端口 $SERVICE_PORT)..."
|
||||||
|
# 杀掉旧进程
|
||||||
|
pkill -f "python3 manage.py runserver $SERVICE_PORT" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 启动新服务
|
||||||
|
mkdir -p ../logs
|
||||||
|
nohup python3 manage.py runserver 0.0.0.0:$SERVICE_PORT > ../logs/server.log 2>&1 &
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# 检查服务状态
|
||||||
|
if curl -s "http://localhost:$SERVICE_PORT/api/stats/" > /dev/null; then
|
||||||
|
echo "✅ 服务启动成功!"
|
||||||
|
echo "📍 API 地址: http://localhost:$SERVICE_PORT/api/"
|
||||||
|
echo "📊 统计接口: http://localhost:$SERVICE_PORT/api/stats/"
|
||||||
|
else
|
||||||
|
echo "❌ 服务启动失败,查看日志:"
|
||||||
|
tail -20 ../logs/server.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📝 常用命令:"
|
||||||
|
echo " 查看日志: tail -f $DEPLOY_DIR/logs/server.log"
|
||||||
|
echo " 停止服务: pkill -f 'python3 manage.py runserver $SERVICE_PORT'"
|
||||||
|
echo " 重启服务: bash $0"
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"antd": "^5.0.0",
|
"antd": "^5.0.0",
|
||||||
"react-diff-viewer-continued": "^3.2.6",
|
"react-diff-viewer-continued": "^3.2.6",
|
||||||
"axios": "^1.0.0"
|
"axios": "^1.0.0",
|
||||||
|
"diff": "^5.1.0",
|
||||||
|
"react-syntax-highlighter": "^15.5.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -1,8 +1,44 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Spin, Alert, Tabs } from 'antd';
|
import { Spin, Alert, Tag, Button, Descriptions, Space, Tooltip, Badge } from 'antd';
|
||||||
import ReactDiffViewer from 'react-diff-viewer-continued';
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { diffLines, ChangeType } from 'diff';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
consistent: {
|
||||||
|
color: 'success',
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
text: '内容一致',
|
||||||
|
description: '本地文件与数据库内容完全相同',
|
||||||
|
},
|
||||||
|
local_newer: {
|
||||||
|
color: 'warning',
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
text: '本地更新',
|
||||||
|
description: '本地文件比数据库更新',
|
||||||
|
},
|
||||||
|
db_newer: {
|
||||||
|
color: 'info',
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
text: '数据库更新',
|
||||||
|
description: '数据库文件比本地更新',
|
||||||
|
},
|
||||||
|
conflict: {
|
||||||
|
color: 'error',
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
text: '存在冲突',
|
||||||
|
description: '本地与数据库内容不一致',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function FileDiff({ filePath, lobsterId }) {
|
export default function FileDiff({ filePath, lobsterId }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [diffData, setDiffData] = useState(null);
|
const [diffData, setDiffData] = useState(null);
|
||||||
@@ -14,13 +50,17 @@ export default function FileDiff({ filePath, lobsterId }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/diff/', {
|
const response = await api.get('/diff/', {
|
||||||
params: { file_path: filePath, lobster_id: lobsterId }
|
params: {
|
||||||
|
lobster_id,
|
||||||
|
file_path: filePath,
|
||||||
|
chunked: 'true',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.data.success) {
|
||||||
setDiffData(response.data);
|
setDiffData(response.data.data);
|
||||||
} else {
|
} else {
|
||||||
setError(response.error || '加载失败');
|
setError(response.data.error || '加载失败');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || '网络错误');
|
setError(err.message || '网络错误');
|
||||||
@@ -33,120 +73,187 @@ export default function FileDiff({ filePath, lobsterId }) {
|
|||||||
if (filePath) {
|
if (filePath) {
|
||||||
loadDiff();
|
loadDiff();
|
||||||
}
|
}
|
||||||
}, [filePath]);
|
}, [filePath, lobsterId]);
|
||||||
|
|
||||||
|
const renderDiff = () => {
|
||||||
|
if (!diffData) return null;
|
||||||
|
|
||||||
|
const { local_content, db_content, diff, status } = diffData;
|
||||||
|
|
||||||
|
if (!local_content || !db_content) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="文件不存在"
|
||||||
|
description={local_content ? '数据库中不存在此文件' : '本地不存在此文件'}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 diff 库计算行级差异
|
||||||
|
const changes = diffLines(db_content || '', local_content || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
||||||
|
<Descriptions
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
column={2}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="状态" span={2}>
|
||||||
|
<Badge
|
||||||
|
status={STATUS_CONFIG[status]?.color}
|
||||||
|
text={
|
||||||
|
<Space>
|
||||||
|
{STATUS_CONFIG[status]?.icon}
|
||||||
|
{STATUS_CONFIG[status]?.text}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{diff.lines_changed !== 0 && (
|
||||||
|
<Descriptions.Item label="变动行数" span={2}>
|
||||||
|
<Tag color={diff.lines_changed > 0 ? 'green' : 'red'}>
|
||||||
|
{diff.lines_changed > 0 ? '+' : ''}{diff.lines_changed}
|
||||||
|
</Tag>
|
||||||
|
{diff.is_truncated && (
|
||||||
|
<Tooltip title="大文件,仅显示头尾差异">
|
||||||
|
<Tag color="orange">已截断</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="本地哈希" span={1}>
|
||||||
|
<code style={{ fontSize: '12px' }}>
|
||||||
|
{diffData.local_hash?.slice(0, 16)}...
|
||||||
|
</code>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="数据库哈希" span={1}>
|
||||||
|
<code style={{ fontSize: '12px' }}>
|
||||||
|
{diffData.db_hash?.slice(0, 16)}...
|
||||||
|
</code>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<div className="diff-container">
|
||||||
|
{changes.map((change, index) => {
|
||||||
|
const lineStyle = {
|
||||||
|
paddingLeft: '16px',
|
||||||
|
paddingRight: '16px',
|
||||||
|
margin: '2px 0',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (change.type === ChangeType.Insert) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
...lineStyle,
|
||||||
|
backgroundColor: '#e6fffb',
|
||||||
|
borderLeft: '3px solid #52c41a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#52c41a', marginRight: '8px' }}>+</span>
|
||||||
|
{change.value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (change.type === ChangeType.Delete) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
...lineStyle,
|
||||||
|
backgroundColor: '#fff1f0',
|
||||||
|
borderLeft: '3px solid #ff4d4f',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#ff4d4f', marginRight: '8px' }}>-</span>
|
||||||
|
{change.value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div key={index} style={{ ...lineStyle }}>
|
||||||
|
<span style={{ color: '#d9d9d9', marginRight: '8px' }}> </span>
|
||||||
|
{change.value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Spin tip="加载中..." />;
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||||
|
<Spin size="large" tip="加载中..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Alert message={error} type="error" />;
|
return (
|
||||||
|
<Alert
|
||||||
|
message="加载失败"
|
||||||
|
description={error}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={loadDiff}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!diffData) {
|
if (!diffData) {
|
||||||
return <Alert message="请选择文件" type="info" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { local_content, db_content, status, diff } = diffData;
|
|
||||||
|
|
||||||
// 文件不存在的情况
|
|
||||||
if (!local_content && !db_content) {
|
|
||||||
return <Alert message="文件不存在" type="warning" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!local_content) {
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
message="文件仅存在于数据库"
|
message="请选择文件"
|
||||||
description="点击「同步到本地」将文件恢复到本地"
|
description="点击左侧文件树中的文件查看差异"
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!db_content) {
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
message="文件仅存在于本地"
|
|
||||||
description="点击「同步到数据库」将文件备份到数据库"
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_MESSAGES = {
|
|
||||||
consistent: '文件内容一致',
|
|
||||||
local_newer: '本地文件有更新',
|
|
||||||
db_newer: '数据库版本更新',
|
|
||||||
conflict: '文件内容冲突',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="file-diff">
|
||||||
<Alert
|
<div style={{ marginBottom: 16 }}>
|
||||||
message={STATUS_MESSAGES[status] || '未知状态'}
|
<Space>
|
||||||
type={status === 'consistent' ? 'success' : 'warning'}
|
<Button
|
||||||
style={{ marginBottom: 16 }}
|
size="small"
|
||||||
showIcon
|
icon={<ClockCircleOutlined />}
|
||||||
/>
|
onClick={loadDiff}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tabs
|
{STATUS_CONFIG[diffData.status] && (
|
||||||
defaultActiveKey="diff"
|
<Alert
|
||||||
items={[
|
message={STATUS_CONFIG[diffData.status].text}
|
||||||
{
|
description={STATUS_CONFIG[diffData.status].description}
|
||||||
key: 'diff',
|
type={STATUS_CONFIG[diffData.status].color}
|
||||||
label: '差异对比',
|
showIcon
|
||||||
children: (
|
icon={STATUS_CONFIG[diffData.status].icon}
|
||||||
<div style={{ overflowX: 'auto' }}>
|
style={{ marginBottom: 16 }}
|
||||||
<ReactDiffViewer
|
closable
|
||||||
oldValue={db_content || ''}
|
/>
|
||||||
newValue={local_content || ''}
|
)}
|
||||||
splitView={true}
|
|
||||||
useDarkTheme={false}
|
{renderDiff()}
|
||||||
leftTitle="数据库版本"
|
|
||||||
rightTitle="本地版本"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'local',
|
|
||||||
label: '本地内容',
|
|
||||||
children: (
|
|
||||||
<pre style={{
|
|
||||||
padding: '16px',
|
|
||||||
background: '#f5f5f5',
|
|
||||||
borderRadius: '4px',
|
|
||||||
maxHeight: '500px',
|
|
||||||
overflow: 'auto',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word'
|
|
||||||
}}>
|
|
||||||
{local_content}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'db',
|
|
||||||
label: '数据库内容',
|
|
||||||
children: (
|
|
||||||
<pre style={{
|
|
||||||
padding: '16px',
|
|
||||||
background: '#f5f5f5',
|
|
||||||
borderRadius: '4px',
|
|
||||||
maxHeight: '500px',
|
|
||||||
overflow: 'auto',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word'
|
|
||||||
}}>
|
|
||||||
{db_content}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user