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: 分块读取与流式传输
|
||||
|
||||
### 问题
|
||||
- 如果龙虾的记忆文件(比如某些 Log 或向量快照)超过 50MB
|
||||
- 一次性 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. 更新前端界面(添加历史记录和忽略规则管理)
|
||||
|
||||
---
|
||||
|
||||
**感谢逍遥子的宝贵建议!** 🙏
|
||||
**项目仓库**: http://10.2.0.100:8989/daotong/lobster-memory-sync.git
|
||||
**维护者**: 道童
|
||||
**版本**: 1.0.0
|
||||
**发布日期**: 2026-04-05
|
||||
855
README.md
855
README.md
@@ -1,190 +1,321 @@
|
||||
# 🦐 龙虾记忆同步系统
|
||||
|
||||
一个用于同步和管理龙虾记忆文件的前后端分离系统,提供文件树展示、差异对比和双向同步功能。
|
||||
一个专为 OpenClaw 龙虾设计的记忆文件管理系统,提供文件树展示、差异对比、双向同步和属性管理功能。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- ✅ **分块流式处理**:8KB 分块读取,内存限制 256MB,支持大文件处理
|
||||
- ✅ **.lobsterignore 支持**:正则表达式匹配,过滤不需要同步的文件
|
||||
- ✅ **智能差异对比**:行级差异,颜色区分,支持大文件截断
|
||||
- ✅ **属性目录结构**:支持嵌套属性键值对(如 `author.name`, `metadata.tags`)
|
||||
- ✅ **完整审计日志**:记录操作人、数据源、变动行数、执行时间
|
||||
- ✅ **语义摘要**:自动生成文件内容摘要
|
||||
- ✅ **冲突判定**:识别 HARD_CONFLICT 状态,智能判断严重冲突
|
||||
- ✅ **丝滑前端**:Ant Design 树形控件,点选-对比-同步流程
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [项目概述](#项目概述)
|
||||
- [技术栈](#技术栈)
|
||||
- [功能特性](#功能特性)
|
||||
- [项目结构](#项目结构)
|
||||
- [快速开始](#快速开始)
|
||||
- [功能特性](#功能特性)
|
||||
- [技术架构](#技术架构)
|
||||
- [项目结构](#项目结构)
|
||||
- [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 Compose
|
||||
- 端口占用检查:8086(前端)、8087(后端)、5432(数据库)
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- 端口:8086(前端)、8087(后端)、5432(数据库)
|
||||
|
||||
### 一键启动
|
||||
|
||||
```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 exec backend python manage.py migrate
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### 访问地址
|
||||
### 访问应用
|
||||
|
||||
- 前端:http://localhost:8086
|
||||
- 后端 API:http://localhost:8087/api/
|
||||
- PostgreSQL:localhost:5432
|
||||
- 📱 前端:http://localhost:8086
|
||||
- 📡 后端 API:http://localhost:8087/api/
|
||||
- 🗄️ 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
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"name": "MEMORY.md",
|
||||
"path": "MEMORY.md",
|
||||
"type": "file",
|
||||
"file_path": "MEMORY.md",
|
||||
"full_path": "/app/memory_files/MEMORY.md",
|
||||
"hash": "abc123...",
|
||||
"size": 1234,
|
||||
"last_modified": "2026-04-05T12:00:00Z"
|
||||
"lobster_id": "daotong"
|
||||
}
|
||||
]
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 检查同步状态
|
||||
|
||||
```
|
||||
GET /api/status/?lobster_id=daotong&file_path=MEMORY.md
|
||||
```http
|
||||
GET /api/status/?lobster_id=daotong
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"synced": false,
|
||||
"has_difference": true,
|
||||
"difference": "+ 新增内容\n- 删除内容"
|
||||
"success": true,
|
||||
"data": {
|
||||
"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/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"lobster_id": "daotong",
|
||||
"file_path": "MEMORY.md"
|
||||
"file_path": "MEMORY.md",
|
||||
"operator": "逍遥子"
|
||||
}
|
||||
```
|
||||
|
||||
### 同步到本地
|
||||
|
||||
```
|
||||
POST /api/sync/local/
|
||||
Content-Type: application/json
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "已同步到数据库",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"lobster_id": "daotong",
|
||||
"file_path": "MEMORY.md"
|
||||
"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 migrate
|
||||
|
||||
# 创建超级用户
|
||||
python manage.py createsuperuser
|
||||
# 运行测试
|
||||
python test_simple.py
|
||||
|
||||
# 运行开发服务器
|
||||
python manage.py runserver 0.0.0.0:8087
|
||||
@@ -215,488 +346,24 @@ npm start
|
||||
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
|
||||
services:
|
||||
backend:
|
||||
backend:
|
||||
volumes:
|
||||
# 挂载龙虾记忆目录(只读)
|
||||
- /home/node/.openclaw/workspace/daotong:/app/memory_files:ro
|
||||
- /your/path/to/lobster/memory:/app/memory_files:ro
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
- 将 `/home/node/.openclaw/workspace/daotong` 替换为实际的龙虾记忆目录路径
|
||||
- 使用 `:ro` 只读挂载,确保安全性
|
||||
**Q: 如何配置 .lobsterignore?**
|
||||
A: 在龙虾记忆目录创建 `.lobsterignore` 文件,参考 `.lobsterignore.example`
|
||||
|
||||
#### 4. 构建并启动服务
|
||||
**Q: 内存占用过高怎么办?**
|
||||
A: 系统已限制最大内存 256MB,自动清理缓存。如仍有问题,检查是否有大文件正在处理。
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
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
|
||||
**Q: 如何查看操作日志?**
|
||||
A: 访问 `GET /api/history/` 接口,支持按文件、操作类型筛选。
|
||||
|
||||
## 📝 License
|
||||
|
||||
@@ -705,3 +372,7 @@ MIT
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 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', '本地更新'),
|
||||
('db_newer', '数据库更新'),
|
||||
('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='文件相对路径')
|
||||
|
||||
@@ -25,6 +26,7 @@ class LobsterMemory(models.Model):
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='consistent',
|
||||
db_index=True,
|
||||
help_text='同步状态'
|
||||
)
|
||||
|
||||
@@ -32,9 +34,13 @@ class LobsterMemory(models.Model):
|
||||
|
||||
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:
|
||||
db_table = 'lobster_memory'
|
||||
@@ -44,13 +50,22 @@ class LobsterMemory(models.Model):
|
||||
models.Index(fields=['lobster_id', 'file_path']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['updated_at']),
|
||||
models.Index(fields=['lobster_id', 'updated_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.lobster_id}/{self.file_path} (v{self.version})"
|
||||
|
||||
def compute_hash(self, content):
|
||||
"""计算SHA256哈希"""
|
||||
def compute_hash(self, content: str) -> str:
|
||||
"""
|
||||
计算 SHA256 哈希
|
||||
|
||||
Args:
|
||||
content: 文件内容
|
||||
|
||||
Returns:
|
||||
哈希值
|
||||
"""
|
||||
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -61,6 +76,69 @@ class LobsterMemory(models.Model):
|
||||
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):
|
||||
"""同步操作历史记录"""
|
||||
|
||||
@@ -69,6 +147,8 @@ class SyncHistory(models.Model):
|
||||
('sync_to_local', '同步到本地'),
|
||||
('auto_sync', '自动同步'),
|
||||
('manual_merge', '手动合并'),
|
||||
('conflict_resolved', '冲突解决'),
|
||||
('attribute_sync', '属性同步'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
@@ -77,9 +157,15 @@ class SyncHistory(models.Model):
|
||||
('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(
|
||||
max_length=20,
|
||||
@@ -93,6 +179,13 @@ class SyncHistory(models.Model):
|
||||
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='操作前版本')
|
||||
|
||||
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='文件大小(字节)')
|
||||
|
||||
lines_changed = 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='操作时间')
|
||||
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:
|
||||
db_table = 'sync_history'
|
||||
@@ -119,6 +218,7 @@ class SyncHistory(models.Model):
|
||||
models.Index(fields=['action']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['lobster_id', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -5,6 +5,8 @@ from .models import LobsterMemory, SyncHistory
|
||||
class LobsterMemorySerializer(serializers.ModelSerializer):
|
||||
"""龙虾记忆序列化器"""
|
||||
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = LobsterMemory
|
||||
fields = [
|
||||
@@ -14,8 +16,10 @@ class LobsterMemorySerializer(serializers.ModelSerializer):
|
||||
'content',
|
||||
'hash',
|
||||
'status',
|
||||
'status_display',
|
||||
'version',
|
||||
'size',
|
||||
'summary',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
@@ -27,6 +31,7 @@ class SyncHistorySerializer(serializers.ModelSerializer):
|
||||
|
||||
action_display = serializers.CharField(source='get_action_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:
|
||||
model = SyncHistory
|
||||
@@ -38,11 +43,14 @@ class SyncHistorySerializer(serializers.ModelSerializer):
|
||||
'action_display',
|
||||
'status',
|
||||
'status_display',
|
||||
'source',
|
||||
'source_display',
|
||||
'old_version',
|
||||
'new_version',
|
||||
'old_hash',
|
||||
'new_hash',
|
||||
'file_size',
|
||||
'lines_changed',
|
||||
'operator',
|
||||
'error_message',
|
||||
'execution_time',
|
||||
@@ -56,9 +64,10 @@ class FileDiffSerializer(serializers.Serializer):
|
||||
|
||||
file_path = serializers.CharField()
|
||||
lobster_id = serializers.CharField()
|
||||
local_content = serializers.CharField(required=False)
|
||||
db_content = serializers.CharField(required=False)
|
||||
local_hash = serializers.CharField(required=False)
|
||||
db_hash = serializers.CharField(required=False)
|
||||
local_content = serializers.CharField(required=False, allow_null=True)
|
||||
db_content = serializers.CharField(required=False, allow_null=True)
|
||||
local_hash = serializers.CharField(required=False, allow_null=True)
|
||||
db_hash = serializers.CharField(required=False, allow_null=True)
|
||||
status = serializers.CharField()
|
||||
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 re
|
||||
import hashlib
|
||||
import fnmatch
|
||||
import time
|
||||
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.utils import timezone
|
||||
|
||||
|
||||
class IgnorePattern:
|
||||
""".lobsterignore 模式匹配器"""
|
||||
"""
|
||||
.lobsterignore 模式匹配器(支持正则表达式)
|
||||
|
||||
支持的匹配规则:
|
||||
1. 通配符:*.pyc, node_modules/
|
||||
2. 目录:__pycache__/
|
||||
3. 正则表达式:re:.*\.log$
|
||||
4. 注释:# 开头的行为注释
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: Path):
|
||||
self.base_dir = base_dir
|
||||
self.patterns = []
|
||||
self.patterns = [] # (pattern_type, pattern, compiled_regex)
|
||||
self.load_patterns()
|
||||
|
||||
def load_patterns(self):
|
||||
"""加载 .lobsterignore 文件"""
|
||||
"""
|
||||
加载 .lobsterignore 文件
|
||||
|
||||
默认忽略规则:
|
||||
- .git, .gitignore
|
||||
- node_modules
|
||||
- .pyc, __pycache__
|
||||
"""
|
||||
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)
|
||||
if not line or line.startswith('#'):
|
||||
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 = [
|
||||
'.DS_Store', '.git', '.gitignore', '__pycache__',
|
||||
'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:
|
||||
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:
|
||||
"""
|
||||
@@ -46,35 +88,61 @@ class IgnorePattern:
|
||||
file_path: 文件路径(绝对路径)
|
||||
|
||||
Returns:
|
||||
是否被忽略
|
||||
True 表示忽略,False 表示不忽略
|
||||
"""
|
||||
# 获取相对路径
|
||||
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_type, pattern, regex in self.patterns:
|
||||
if pattern_type == 'regex':
|
||||
# 正则表达式匹配
|
||||
if regex.search(relative_str) or regex.search(filename):
|
||||
return True
|
||||
else:
|
||||
# 通配符匹配
|
||||
from fnmatch import fnmatch
|
||||
|
||||
for pattern in self.patterns:
|
||||
# 匹配文件名
|
||||
if fnmatch.fnmatch(file_path.name, pattern):
|
||||
if fnmatch(filename, pattern):
|
||||
return True
|
||||
|
||||
# 匹配相对路径
|
||||
if fnmatch.fnmatch(str(relative_path), pattern):
|
||||
if fnmatch(relative_str, pattern):
|
||||
return True
|
||||
|
||||
# 匹配目录
|
||||
if pattern.endswith('/') and fnmatch.fnmatch(str(relative_path.parent), pattern.rstrip('/')):
|
||||
# 匹配目录(检查路径的每个部分)
|
||||
if pattern.endswith('/') or pattern in ['node_modules', '__pycache__', '.git']:
|
||||
# 检查路径中是否包含该目录
|
||||
parts = relative_str.split(os.sep)
|
||||
dir_pattern = pattern.rstrip('/')
|
||||
if dir_pattern in parts:
|
||||
return True
|
||||
# 检查是否是该目录下的文件
|
||||
if fnmatch(relative_str, f"{dir_pattern}/*"):
|
||||
return True
|
||||
|
||||
# 递归匹配子目录
|
||||
if pattern.startswith('*/'):
|
||||
parts = str(relative_path).split(os.sep)
|
||||
for i, part in enumerate(parts):
|
||||
if fnmatch.fnmatch(part, pattern[2:]):
|
||||
parts = relative_str.split(os.sep)
|
||||
for part in parts:
|
||||
if fnmatch(part, pattern[2:]):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class FileScanner:
|
||||
"""文件扫描器(支持 .lobsterignore 和分块读取)"""
|
||||
"""
|
||||
文件扫描器(支持 .lobsterignore 和分块读取)
|
||||
|
||||
所有文件读取操作都使用 8KB 分块,避免大文件内存问题
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_dir = Path(settings.LOBSTER_MEMORY_BASE)
|
||||
@@ -111,7 +179,7 @@ class FileScanner:
|
||||
try:
|
||||
relative_path = file_path.relative_to(self.base_dir)
|
||||
|
||||
# 使用流式读取获取哈希(避免大文件内存问题)
|
||||
# 使用流式计算哈希(避免大文件内存问题)
|
||||
file_hash = self.compute_hash_stream(file_path)
|
||||
|
||||
files.append({
|
||||
@@ -126,13 +194,13 @@ class FileScanner:
|
||||
|
||||
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:
|
||||
file_path: 相对路径
|
||||
chunked: 是否使用分块读取
|
||||
chunked: 是否使用分块读取(默认 True)
|
||||
|
||||
Returns:
|
||||
(content, hash)
|
||||
@@ -142,9 +210,8 @@ class FileScanner:
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
# 对于大文件(>50MB),使用分块读取
|
||||
file_size = full_path.stat().st_size
|
||||
if chunked and file_size > 50 * 1024 * 1024:
|
||||
# 默认使用分块读取
|
||||
if chunked:
|
||||
content = self.read_file_chunked(full_path)
|
||||
else:
|
||||
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:
|
||||
"""
|
||||
分块读取文件
|
||||
分块读取文件(8KB 分块)
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
@@ -180,7 +247,7 @@ class FileScanner:
|
||||
file_path: 相对路径
|
||||
|
||||
Yields:
|
||||
文件块
|
||||
8KB 文件块
|
||||
"""
|
||||
full_path = self.base_dir / file_path
|
||||
|
||||
@@ -212,7 +279,7 @@ class FileScanner:
|
||||
|
||||
def compute_hash(self, content: str) -> str:
|
||||
"""
|
||||
计算SHA256哈希
|
||||
计算 SHA256 哈希
|
||||
|
||||
Args:
|
||||
content: 文件内容
|
||||
@@ -272,15 +339,69 @@ class FileScanner:
|
||||
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:
|
||||
"""差异检查器(支持大文件优化)"""
|
||||
"""
|
||||
差异检查器(支持大文件优化和冲突判定)
|
||||
|
||||
冲突判定逻辑:
|
||||
- consistent: 哈希相同,内容一致
|
||||
- local_newer: 只有本地存在
|
||||
- db_newer: 只有数据库存在
|
||||
- conflict: 两边都存在但哈希不同
|
||||
- hard_conflict: 两边都存在,哈希不同,且数据库有多个版本变化
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.scanner = FileScanner()
|
||||
|
||||
def check_sync_status(self, local_files: List[Dict], db_files: List[Dict]) -> Dict:
|
||||
"""
|
||||
检查同步状态
|
||||
检查同步状态(完善冲突判定逻辑)
|
||||
|
||||
Args:
|
||||
local_files: 本地文件列表
|
||||
@@ -297,6 +418,7 @@ class DiffChecker:
|
||||
'local_newer': [],
|
||||
'db_newer': [],
|
||||
'conflict': [],
|
||||
'hard_conflict': [],
|
||||
'local_only': [],
|
||||
'db_only': [],
|
||||
}
|
||||
@@ -310,48 +432,94 @@ class DiffChecker:
|
||||
if local and db:
|
||||
# 两边都存在
|
||||
if local['hash'] == db['hash']:
|
||||
# 哈希相同,内容一致
|
||||
results['consistent'].append({
|
||||
'file_path': path,
|
||||
'status': 'consistent'
|
||||
'status': 'consistent',
|
||||
'hash': local['hash']
|
||||
})
|
||||
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:
|
||||
# 数据库有更新时间,比较
|
||||
if local['hash'] != db['hash']:
|
||||
results['conflict'].append({
|
||||
# 判定严重冲突的条件:
|
||||
# 1. 哈希不同
|
||||
# 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': 'conflict',
|
||||
'status': 'hard_conflict',
|
||||
'local_hash': local['hash'],
|
||||
'db_hash': db['hash']
|
||||
'db_hash': db['hash'],
|
||||
'version': version,
|
||||
'updated_at': str(updated_at)
|
||||
})
|
||||
else:
|
||||
# 无法判断,标记为冲突
|
||||
results['conflict'].append({
|
||||
'file_path': path,
|
||||
'status': 'conflict',
|
||||
'local_hash': local['hash'],
|
||||
'db_hash': db['hash']
|
||||
'db_hash': db['hash'],
|
||||
'version': version
|
||||
})
|
||||
else:
|
||||
results['conflict'].append({
|
||||
'file_path': path,
|
||||
'status': 'conflict',
|
||||
'local_hash': local['hash'],
|
||||
'db_hash': db['hash'],
|
||||
'version': version
|
||||
})
|
||||
|
||||
elif local and not db:
|
||||
# 只有本地
|
||||
# 只有本地存在
|
||||
results['local_only'].append({
|
||||
'file_path': path,
|
||||
'status': 'local_only'
|
||||
'status': 'local_only',
|
||||
'hash': local['hash']
|
||||
})
|
||||
|
||||
elif not local and db:
|
||||
# 只有数据库
|
||||
# 只有数据库存在
|
||||
results['db_only'].append({
|
||||
'file_path': path,
|
||||
'status': 'db_only'
|
||||
'status': 'db_only',
|
||||
'hash': db['hash']
|
||||
})
|
||||
|
||||
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:
|
||||
"""
|
||||
获取文件差异(支持大文件限制)
|
||||
@@ -368,26 +536,41 @@ class DiffChecker:
|
||||
db_lines = db_content.split('\n')
|
||||
|
||||
# 限制行数(大文件只显示头尾)
|
||||
truncated = False
|
||||
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
|
||||
local_lines = local_head + [f'... (中间省略 {len(local_lines) - max_lines} 行) ...'] + local_tail
|
||||
truncated = True
|
||||
|
||||
if len(db_lines) > max_lines:
|
||||
db_head = 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 {
|
||||
'local_lines': local_lines,
|
||||
'db_lines': db_lines,
|
||||
'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:
|
||||
"""操作日志记录器"""
|
||||
"""
|
||||
操作日志记录器
|
||||
|
||||
记录所有同步操作,包括:
|
||||
- 操作人、操作时间
|
||||
- 数据源(local/database/manual)
|
||||
- 变动行数
|
||||
- 执行时间
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.model = None
|
||||
@@ -405,6 +588,8 @@ class AuditLogger:
|
||||
old_hash: str = None,
|
||||
new_hash: str = None,
|
||||
file_size: int = 0,
|
||||
lines_changed: int = 0,
|
||||
source: str = 'local',
|
||||
operator: str = 'system',
|
||||
status: str = 'success',
|
||||
error_message: str = None,
|
||||
@@ -422,6 +607,8 @@ class AuditLogger:
|
||||
old_hash: 操作前哈希
|
||||
new_hash: 操作后哈希
|
||||
file_size: 文件大小
|
||||
lines_changed: 变动行数
|
||||
source: 数据源
|
||||
operator: 操作者
|
||||
status: 操作状态
|
||||
error_message: 错误信息
|
||||
@@ -436,6 +623,8 @@ class AuditLogger:
|
||||
old_hash=old_hash,
|
||||
new_hash=new_hash,
|
||||
file_size=file_size,
|
||||
lines_changed=lines_changed,
|
||||
source=source,
|
||||
operator=operator,
|
||||
status=status,
|
||||
error_message=error_message,
|
||||
@@ -482,11 +671,13 @@ class AuditLogger:
|
||||
'file_path': r.file_path,
|
||||
'action': r.action,
|
||||
'status': r.status,
|
||||
'source': r.source,
|
||||
'old_version': r.old_version,
|
||||
'new_version': r.new_version,
|
||||
'old_hash': r.old_hash,
|
||||
'new_hash': r.new_hash,
|
||||
'file_size': r.file_size,
|
||||
'lines_changed': r.lines_changed,
|
||||
'operator': r.operator,
|
||||
'error_message': r.error_message,
|
||||
'execution_time': r.execution_time,
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"""
|
||||
龙虾记忆同步系统 - API 视图模块
|
||||
|
||||
集成所有核心功能:
|
||||
- 分块与流式处理
|
||||
- .lobsterignore 支持
|
||||
- 审计日志
|
||||
- 语义摘要
|
||||
- 完善的冲突判定
|
||||
"""
|
||||
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from .models import LobsterMemory
|
||||
from .serializers import LobsterMemorySerializer, FileDiffSerializer
|
||||
from .services import FileScanner, DiffChecker, AuditLogger
|
||||
import json
|
||||
from .services import (
|
||||
FileScanner, DiffChecker, AuditLogger, SemanticSummaryGenerator
|
||||
)
|
||||
import time
|
||||
|
||||
|
||||
@@ -12,6 +24,9 @@ import time
|
||||
def scan_files(request):
|
||||
"""
|
||||
扫描本地文件
|
||||
|
||||
自动应用 .lobsterignore 规则过滤不需要同步的文件
|
||||
使用流式哈希计算,避免大文件内存问题
|
||||
"""
|
||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||
scanner = FileScanner()
|
||||
@@ -28,7 +43,9 @@ def scan_files(request):
|
||||
@api_view(['GET'])
|
||||
def get_file_tree(request):
|
||||
"""
|
||||
获取文件树
|
||||
获取文件树结构
|
||||
|
||||
展示所有未被 .lobsterignore 过滤的文件
|
||||
"""
|
||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||
scanner = FileScanner()
|
||||
@@ -44,11 +61,20 @@ def get_file_tree(request):
|
||||
@api_view(['GET'])
|
||||
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')
|
||||
|
||||
# 获取本地文件
|
||||
# 获取本地文件(应用 .lobsterignore)
|
||||
scanner = FileScanner()
|
||||
local_files = scanner.scan_directory(lobster_id)
|
||||
|
||||
@@ -57,7 +83,7 @@ def check_sync_status(request):
|
||||
lobster_id=lobster_id
|
||||
).values('file_path', 'hash', 'version', 'updated_at'))
|
||||
|
||||
# 检查同步状态
|
||||
# 检查同步状态(包含 HARD_CONFLICT 判定)
|
||||
checker = DiffChecker()
|
||||
sync_status = checker.check_sync_status(local_files, db_files)
|
||||
|
||||
@@ -71,10 +97,12 @@ def check_sync_status(request):
|
||||
def get_file_diff(request):
|
||||
"""
|
||||
获取文件差异(支持大文件优化)
|
||||
|
||||
使用 8KB 分块读取,计算变动行数
|
||||
"""
|
||||
file_path = request.query_params.get('file_path')
|
||||
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:
|
||||
return Response({
|
||||
@@ -84,7 +112,7 @@ def get_file_diff(request):
|
||||
|
||||
scanner = FileScanner()
|
||||
|
||||
# 获取本地内容(支持分块读取)
|
||||
# 获取本地内容(强制使用分块读取)
|
||||
try:
|
||||
local_content, local_hash = scanner.get_file_content(file_path, chunked=chunked)
|
||||
except FileNotFoundError:
|
||||
@@ -110,7 +138,7 @@ def get_file_diff(request):
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
# 获取差异(支持大文件限制)
|
||||
# 获取差异(支持大文件限制,计算变动行数)
|
||||
checker = DiffChecker()
|
||||
if local_content and 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 [],
|
||||
'db_lines': db_content.split('\n') if db_content else [],
|
||||
'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'])
|
||||
def sync_to_db(request):
|
||||
"""
|
||||
同步到数据库(带操作日志)
|
||||
同步到数据库(带完整审计日志)
|
||||
|
||||
功能:
|
||||
- 使用分块读取文件
|
||||
- 生成语义摘要
|
||||
- 记录变动行数
|
||||
- 记录数据源、操作人、执行时间
|
||||
"""
|
||||
lobster_id = request.data.get('lobster_id', 'daotong')
|
||||
file_path = request.data.get('file_path')
|
||||
@@ -164,12 +199,13 @@ def sync_to_db(request):
|
||||
|
||||
scanner = FileScanner()
|
||||
audit_logger = AuditLogger()
|
||||
summary_generator = SemanticSummaryGenerator()
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
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(
|
||||
@@ -179,6 +215,13 @@ def sync_to_db(request):
|
||||
|
||||
old_version = existing.version 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:
|
||||
# 创建新版本
|
||||
@@ -186,6 +229,9 @@ def sync_to_db(request):
|
||||
else:
|
||||
new_version = 1
|
||||
|
||||
# 生成语义摘要
|
||||
summary = summary_generator.generate_summary(content)
|
||||
|
||||
# 创建新记录
|
||||
record = LobsterMemory.objects.create(
|
||||
lobster_id=lobster_id,
|
||||
@@ -194,11 +240,12 @@ def sync_to_db(request):
|
||||
hash=file_hash,
|
||||
status='consistent',
|
||||
version=new_version,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# 记录操作日志
|
||||
# 记录操作日志(包含变动行数和数据源)
|
||||
audit_logger.log_sync_action(
|
||||
lobster_id=lobster_id,
|
||||
file_path=file_path,
|
||||
@@ -208,6 +255,8 @@ def sync_to_db(request):
|
||||
old_hash=old_hash,
|
||||
new_hash=file_hash,
|
||||
file_size=record.size,
|
||||
lines_changed=lines_changed,
|
||||
source='local',
|
||||
operator=operator,
|
||||
status='success',
|
||||
execution_time=execution_time
|
||||
@@ -227,6 +276,7 @@ def sync_to_db(request):
|
||||
lobster_id=lobster_id,
|
||||
file_path=file_path,
|
||||
action='sync_to_db',
|
||||
source='local',
|
||||
operator=operator,
|
||||
status='failed',
|
||||
error_message=str(e),
|
||||
@@ -242,7 +292,11 @@ def sync_to_db(request):
|
||||
@api_view(['POST'])
|
||||
def sync_to_local(request):
|
||||
"""
|
||||
同步到本地(带操作日志)
|
||||
同步到本地(带完整审计日志)
|
||||
|
||||
功能:
|
||||
- 记录变动行数
|
||||
- 记录数据源、操作人、执行时间
|
||||
"""
|
||||
lobster_id = request.data.get('lobster_id', 'daotong')
|
||||
file_path = request.data.get('file_path')
|
||||
@@ -274,16 +328,23 @@ def sync_to_local(request):
|
||||
|
||||
# 获取本地哈希(如果存在)
|
||||
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:
|
||||
local_content = 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)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# 记录操作日志
|
||||
# 记录操作日志(包含变动行数和数据源)
|
||||
audit_logger.log_sync_action(
|
||||
lobster_id=lobster_id,
|
||||
file_path=file_path,
|
||||
@@ -293,6 +354,8 @@ def sync_to_local(request):
|
||||
old_hash=local_hash,
|
||||
new_hash=db_record.hash,
|
||||
file_size=db_record.size,
|
||||
lines_changed=lines_changed,
|
||||
source='database',
|
||||
operator=operator,
|
||||
status='success',
|
||||
execution_time=execution_time
|
||||
@@ -312,6 +375,7 @@ def sync_to_local(request):
|
||||
lobster_id=lobster_id,
|
||||
file_path=file_path,
|
||||
action='sync_to_local',
|
||||
source='database',
|
||||
operator=operator,
|
||||
status='failed',
|
||||
error_message=str(e),
|
||||
@@ -327,7 +391,7 @@ def sync_to_local(request):
|
||||
@api_view(['GET'])
|
||||
def get_versions(request):
|
||||
"""
|
||||
获取文件的所有版本
|
||||
获取文件的所有版本(包含摘要)
|
||||
"""
|
||||
file_path = request.query_params.get('file_path')
|
||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||
@@ -352,7 +416,7 @@ def get_versions(request):
|
||||
@api_view(['GET'])
|
||||
def get_stats(request):
|
||||
"""
|
||||
获取统计信息
|
||||
获取统计信息(包含 hard_conflict 状态)
|
||||
"""
|
||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||
|
||||
@@ -386,7 +450,7 @@ def get_stats(request):
|
||||
@api_view(['GET'])
|
||||
def get_history(request):
|
||||
"""
|
||||
获取操作历史
|
||||
获取操作历史(包含变动行数和数据源)
|
||||
"""
|
||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||
file_path = request.query_params.get('file_path')
|
||||
@@ -412,11 +476,21 @@ def get_history(request):
|
||||
def get_ignore_patterns(request):
|
||||
"""
|
||||
获取 .lobsterignore 模式列表
|
||||
|
||||
显示所有生效的忽略规则,包括:
|
||||
- 通配符模式 (*.pyc)
|
||||
- 正则表达式模式 (re:.*\\.log\$)
|
||||
- 默认规则
|
||||
"""
|
||||
lobster_id = request.query_params.get('lobster_id', 'daotong')
|
||||
scanner = FileScanner()
|
||||
|
||||
patterns = scanner.ignore.patterns
|
||||
patterns = []
|
||||
for pattern_type, pattern, _ in scanner.ignore.patterns:
|
||||
patterns.append({
|
||||
'type': pattern_type,
|
||||
'pattern': pattern
|
||||
})
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
@@ -431,6 +505,8 @@ def get_ignore_patterns(request):
|
||||
def reload_ignore_patterns(request):
|
||||
"""
|
||||
重新加载 .lobsterignore 模式
|
||||
|
||||
当修改 .lobsterignore 文件后调用此接口
|
||||
"""
|
||||
lobster_id = request.data.get('lobster_id', 'daotong')
|
||||
scanner = FileScanner()
|
||||
@@ -438,11 +514,18 @@ def reload_ignore_patterns(request):
|
||||
# 重新加载忽略规则
|
||||
scanner.ignore.load_patterns()
|
||||
|
||||
patterns = []
|
||||
for pattern_type, pattern, _ in scanner.ignore.patterns:
|
||||
patterns.append({
|
||||
'type': pattern_type,
|
||||
'pattern': pattern
|
||||
})
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': '已重新加载忽略规则',
|
||||
'data': {
|
||||
'patterns': scanner.ignore.patterns,
|
||||
'total': len(scanner.ignore.patterns)
|
||||
'patterns': 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",
|
||||
"antd": "^5.0.0",
|
||||
"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": {
|
||||
"start": "react-scripts start",
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Spin, Alert, Tabs } from 'antd';
|
||||
import ReactDiffViewer from 'react-diff-viewer-continued';
|
||||
import { Spin, Alert, Tag, Button, Descriptions, Space, Tooltip, Badge } from 'antd';
|
||||
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';
|
||||
|
||||
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 }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [diffData, setDiffData] = useState(null);
|
||||
@@ -14,13 +50,17 @@ export default function FileDiff({ filePath, lobsterId }) {
|
||||
|
||||
try {
|
||||
const response = await api.get('/diff/', {
|
||||
params: { file_path: filePath, lobster_id: lobsterId }
|
||||
params: {
|
||||
lobster_id,
|
||||
file_path: filePath,
|
||||
chunked: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setDiffData(response.data);
|
||||
if (response.data.success) {
|
||||
setDiffData(response.data.data);
|
||||
} else {
|
||||
setError(response.error || '加载失败');
|
||||
setError(response.data.error || '加载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || '网络错误');
|
||||
@@ -33,120 +73,187 @@ export default function FileDiff({ filePath, lobsterId }) {
|
||||
if (filePath) {
|
||||
loadDiff();
|
||||
}
|
||||
}, [filePath]);
|
||||
}, [filePath, lobsterId]);
|
||||
|
||||
if (loading) {
|
||||
return <Spin tip="加载中..." />;
|
||||
}
|
||||
const renderDiff = () => {
|
||||
if (!diffData) return null;
|
||||
|
||||
if (error) {
|
||||
return <Alert message={error} type="error" />;
|
||||
}
|
||||
const { local_content, db_content, diff, status } = 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) {
|
||||
if (!local_content || !db_content) {
|
||||
return (
|
||||
<Alert
|
||||
message="文件仅存在于数据库"
|
||||
description="点击「同步到本地」将文件恢复到本地"
|
||||
message="文件不存在"
|
||||
description={local_content ? '数据库中不存在此文件' : '本地不存在此文件'}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!db_content) {
|
||||
// 使用 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) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
message="文件仅存在于本地"
|
||||
description="点击「同步到数据库」将文件备份到数据库"
|
||||
type="warning"
|
||||
message="加载失败"
|
||||
description={error}
|
||||
type="error"
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" onClick={loadDiff}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!diffData) {
|
||||
return (
|
||||
<Alert
|
||||
message="请选择文件"
|
||||
description="点击左侧文件树中的文件查看差异"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_MESSAGES = {
|
||||
consistent: '文件内容一致',
|
||||
local_newer: '本地文件有更新',
|
||||
db_newer: '数据库版本更新',
|
||||
conflict: '文件内容冲突',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
message={STATUS_MESSAGES[status] || '未知状态'}
|
||||
type={status === 'consistent' ? 'success' : 'warning'}
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="diff"
|
||||
items={[
|
||||
{
|
||||
key: 'diff',
|
||||
label: '差异对比',
|
||||
children: (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<ReactDiffViewer
|
||||
oldValue={db_content || ''}
|
||||
newValue={local_content || ''}
|
||||
splitView={true}
|
||||
useDarkTheme={false}
|
||||
leftTitle="数据库版本"
|
||||
rightTitle="本地版本"
|
||||
/>
|
||||
<div className="file-diff">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ClockCircleOutlined />}
|
||||
onClick={loadDiff}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
{STATUS_CONFIG[diffData.status] && (
|
||||
<Alert
|
||||
message={STATUS_CONFIG[diffData.status].text}
|
||||
description={STATUS_CONFIG[diffData.status].description}
|
||||
type={STATUS_CONFIG[diffData.status].color}
|
||||
showIcon
|
||||
icon={STATUS_CONFIG[diffData.status].icon}
|
||||
style={{ marginBottom: 16 }}
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
|
||||
{renderDiff()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user