Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
379 lines
14 KiB
Python
379 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate comprehensive gap analysis report comparing GoChat vs Chatwoot.
|
|
Focus on actionable metrics, not raw counts."""
|
|
import re
|
|
from collections import defaultdict
|
|
import subprocess
|
|
|
|
import json
|
|
|
|
import os
|
|
|
|
from datetime import datetime
|
|
|
|
now_str = datetime.now().strftime("%Y%m%d_%H%M")
|
|
|
|
def normalize(path):
|
|
"""Normalize param names for comparison. Replace all :xxx_id with generic :id,
|
|
but keep sub-resource segments (avatar, config, etc.) intact."""
|
|
# Replace :xxx_id patterns
|
|
path = re.sub(r':([a-zA-Z_]+)_id', ':id', path)
|
|
# Replace other named params (website_token, slug, etc.)
|
|
path = re.sub(r':(website_token|slug|identifier|channel_type|fb_id|locale|category_slug|article_slug|portal_id|user_id)', ':id', path)
|
|
# Remove trailing slashes
|
|
path = path.rstrip('/')
|
|
return path
|
|
|
|
def normalize_gochat(path):
|
|
"""Normalize GoChat params — same logic but GoChat uses specific param names."""
|
|
path = re.sub(r':([a-zA-Z_]+)_id', ':id', path)
|
|
path = re.sub(r':(website_token|slug|identifier|channel_type|fb_id|locale|category_slug|article_slug|portal_id|user_id|thread_id)', ':id', path)
|
|
path = path.rstrip('/')
|
|
return path
|
|
|
|
def load_routes(filepath, normalize_func):
|
|
routes = {}
|
|
with open(filepath) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
parts = line.split(' ', 1)
|
|
if len(parts) == 2:
|
|
method, path = parts
|
|
norm = normalize_func(path)
|
|
routes[(method, norm)] = line.strip()
|
|
return routes
|
|
|
|
def extract_resource_name(path):
|
|
"""Extract the top-level resource name from a normalized path."""
|
|
# E.g. /api/v1/accounts/:id/agents/:id → accounts
|
|
m = re.match(r'/api/v\d/(accounts|platform|public)?/[^/]+', path)
|
|
if m:
|
|
return m.group(0)
|
|
# E.g. /api/v1/agent_bots/:id → agent_bots
|
|
m = re.match(r'/api/v\d/([^/]+)', path)
|
|
if m:
|
|
return m.group(0)
|
|
return path.split('/:id')[0].split('/')[-1]
|
|
|
|
def get_gochat_stats():
|
|
"""Get GoChat handler/model/service file counts."""
|
|
stats = {}
|
|
|
|
# Handler files
|
|
handler_files = subprocess.run(
|
|
"find /home/yanghao05/Workspace/gochat/internal/handler -name '*.go' | wc -l",
|
|
shell=True, capture_output=True
|
|
).stdout.decode().decode().strip()
|
|
stats['handlers'] = int(handler_files) if handler_files.isdigit() else 0
|
|
|
|
# Model files
|
|
model_files = subprocess.run(
|
|
"find /home/yanghao05/Workspace/gochat/internal/model -name '*.go' | wc -l",
|
|
shell=True, capture_output=True
|
|
).stdout.decode().decode().strip()
|
|
stats['models'] = int(model_files) if model_files.isdigit() else 0
|
|
|
|
# Service files
|
|
service_files = subprocess.run(
|
|
"find /home/yanghao05/Workspace/gochat/internal/service -name '*.go' | wc -l",
|
|
shell=True, capture_output=True
|
|
).stdout.decode().decode().strip()
|
|
stats['services'] = int(service_files) if service_files.isdigit() else 0
|
|
|
|
# Test files
|
|
test_files = subprocess.run(
|
|
"find /home/yanghao05/Workspace/gochat -path '*_test.go' -name '*.go' | wc -l",
|
|
shell=True, capture_output=True
|
|
).stdout.decode().decode().strip()
|
|
stats['tests'] = int(test_files) if test_files.isdigit() else 0
|
|
|
|
# Count handler implementations (stub vs real)
|
|
stub_count = 0
|
|
real_count = 0
|
|
for f in subprocess.run(
|
|
"find /home/yanghao05/Workspace/gochat/internal/handler -name '*.go'",
|
|
shell=True, capture_output=True
|
|
).stdout.decode().decode().strip().split('\n'):
|
|
if f.strip():
|
|
with open(f.strip()) as fh:
|
|
content = fh.read()
|
|
if 'TODO' in content or 'placeholder' in content or 'stub' in content.lower():
|
|
stub_count += 1
|
|
else:
|
|
real_count += 1
|
|
stats['stub_handlers'] = stub_count
|
|
stats['real_handlers'] = real_count
|
|
|
|
return stats
|
|
|
|
def get_chatwoot_stats():
|
|
"""Get Chatwoot controller/model/service counts."""
|
|
stats = {}
|
|
|
|
# Controller files
|
|
ctrl_files = subprocess.run(
|
|
"find /home/yanghao05/Workspace/chatwoot-reference/app/controllers -name '*.rb' | wc -l",
|
|
shell=True, capture_output=True
|
|
).stdout.decode().decode().strip()
|
|
stats['controllers'] = int(ctrl_files) if ctrl_files.isdigit() else 0
|
|
|
|
# Model files
|
|
model_files = subprocess.run(
|
|
"find /home/yanghao05/Workspace/chatwoot-reference/app/models -name '*.rb' | wc -l",
|
|
shell=True, capture_output=True
|
|
).stdout.decode().decode().strip()
|
|
stats['models'] = int(model_files) if model_files.isdigit() else 0
|
|
|
|
# Service files
|
|
service_files = subprocess.run(
|
|
"find /home/yanghao05/Workspace/chatwoot-reference/app/services -name '*.rb' | wc -l",
|
|
shell=True, capture_output=True
|
|
).stdout.decode().decode().strip()
|
|
stats['services'] = int(service_files) if service_files.isdigit() else 0
|
|
|
|
return stats
|
|
|
|
def get_chatwoot_resources():
|
|
"""Extract all unique resource names from Chatwoot routes."""
|
|
resources = set()
|
|
result = subprocess.run(
|
|
"cd /home/yanghao05/Workspace/gochat && python3 scripts/parse_chatwoot_routes_v3.py 2>&1 | grep -E '^(GET|POST|PUT|PATCH|DELETE)' | sort",
|
|
shell=True, capture_output=True
|
|
)
|
|
for line in result.stdout.decode().decode().strip().split('\n'):
|
|
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/api/v\d/(?:accounts/\:id/)?([^/]+)', line)
|
|
if m:
|
|
resources.add(m.group(2))
|
|
return sorted(resources)
|
|
|
|
def get_gochat_resources():
|
|
"""Extract all unique resource names from GoChat routes."""
|
|
resources = set()
|
|
result = subprocess.run(
|
|
"cd /home/yanghao05/Workspace/gochat && ./dump_routes 2>&1 | grep -v 'CONNECT' | sort",
|
|
shell=True, capture_output=True
|
|
)
|
|
for line in result.stdout.decode().decode().strip().split('\n'):
|
|
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/api/v\d/accounts/\:account_id/([^/]+)', line)
|
|
if m:
|
|
resources.add(m.group(1))
|
|
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/api/v\d/([^/]+)', line)
|
|
if m:
|
|
resources.add(m.group(1))
|
|
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/platform/api/v\d/([^/]+)', line)
|
|
if m:
|
|
resources.add(m.group(1))
|
|
m = re.match(r'(GET|POST|PUT|PATCH|DELETE)\s+/public/api/v\d/([^/]+)', line)
|
|
if m:
|
|
resources.add(m.group(1))
|
|
return sorted(resources)
|
|
|
|
def compare_resource_coverage():
|
|
"""Compare which resources exist in Chatwoot but not in GoChat."""
|
|
chatwoot_res = get_chatwoot_resources()
|
|
gochat_res = get_gochat_resources()
|
|
|
|
missing = chatwoot_res - gochat_res
|
|
extra = gochat_res - chatwoot_res
|
|
|
|
return chatwoot_res, gochat_res, missing, extra
|
|
|
|
def get_chatwoot_route_details():
|
|
"""Get detailed Chatwoot route list with action names."""
|
|
result = subprocess.run(
|
|
"cd /home/yanghao05/Workspace/gochat && python3 scripts/parse_chatwoot_routes_v3.py 2>&1 | grep -E '^(GET|POST|PUT|PATCH|DELETE)' | sort",
|
|
shell=True, capture_output=True
|
|
)
|
|
routes = {}
|
|
for line in result.stdout.decode().decode().strip().split('\n'):
|
|
parts = line.strip().split(' ', 2)
|
|
if len(parts) == 2:
|
|
method, path = parts
|
|
# Normalize for comparison
|
|
norm = normalize(path)
|
|
routes[(method, norm)] = path
|
|
return routes
|
|
|
|
def get_gochat_route_details():
|
|
"""Get GoChat route details."""
|
|
result = subprocess.run(
|
|
"cd /home/yanghao05/Workspace/gochat && ./dump_routes 2>&1 | grep -v 'CONNECT' | sort",
|
|
shell=True, capture_output=True
|
|
)
|
|
routes = {}
|
|
for line in result.stdout.decode().decode().strip().split('\n'):
|
|
parts = line.strip().split(' ', 1)
|
|
if len(parts) == 2:
|
|
method, path = parts
|
|
norm = normalize_gochat(path)
|
|
routes[(method, norm)] = path
|
|
return routes
|
|
|
|
def generate_report():
|
|
chatwoot = load_routes('/tmp/chatwoot_routes_all.txt', normalize)
|
|
gochat = load_routes('/tmp/gochat_routes_all.txt', normalize_gochat)
|
|
|
|
chatwoot_keys = set(chatwoot.keys())
|
|
gochat_keys = set(gochat.keys())
|
|
|
|
matched = chatwoot_keys & gochat_keys
|
|
missing_in_gochat = chatwoot_keys - gochat_keys
|
|
extra_in_gochat = gochat_keys - chatwoot_keys
|
|
|
|
# Get stats
|
|
gochat_stats = get_gochat_stats()
|
|
chatwoot_stats = get_chatwoot_stats()
|
|
chatwoot_res, gochat_res, missing_res, extra_res = compare_resource_coverage()
|
|
|
|
# Group missing routes by resource
|
|
missing_by_resource = defaultdict(list)
|
|
for key in sorted(missing_in_gochat):
|
|
method, norm_path = key
|
|
orig_path = chatwoot[key]
|
|
# Extract resource name
|
|
parts = norm_path.split('/')
|
|
if len(parts) >= 4:
|
|
# /api/v1/accounts/:id/agents/:id → agents
|
|
resource = '/'.join(parts[:4])
|
|
elif len(parts) >= 3:
|
|
resource = '/'.join(parts[:3])
|
|
else:
|
|
resource = norm_path
|
|
missing_by_resource[resource].append((method, orig_path))
|
|
|
|
# Group extra routes by resource
|
|
extra_by_resource = defaultdict(list)
|
|
for key in sorted(extra_in_gochat):
|
|
method, norm_path = key
|
|
orig_path = gochat[key]
|
|
parts = norm_path.split('/')
|
|
if len(parts) >= 4:
|
|
resource = '/'.join(parts[:4])
|
|
elif len(parts) >= 3:
|
|
resource = '/'.join(parts[:3])
|
|
else:
|
|
resource = norm_path
|
|
extra_by_resource[resource].append((method, orig_path))
|
|
|
|
report_name = f"gochat_vs_chatwoot_gap_report_{now_str}.md"
|
|
|
|
with open(report_name, 'w') as f:
|
|
f.write(f"""# GoChat vs Chatwoot 实现差距报告
|
|
|
|
**生成时间**: {now_str}
|
|
|
|
## 一、总览
|
|
|
|
| | GoChat | Chatwoot |
|
|
| --- | --- | --- |
|
|
| 路由总数 | {len(gochat_keys)} | {len(chatwoot_keys)} |
|
|
| 匹配路由 | {len(matched)} | {len(matched)} |
|
|
| GoChat缺失 | {len(missing_in_gochat)} | — |
|
|
| GoChat多余 | {len(extra_in_gochat)} | — |
|
|
| 覆盖率 | {len(matched)/len(chatwoot_keys)*100:.1f}% | — |
|
|
| Handler文件 | {gochat_stats.get('handlers', 0)} | {chatwoot_stats.get('controllers', 0)} |
|
|
| Model文件 | {gochat_stats.get('models', 0)} | {chatwoot_stats.get('models', 0)} |
|
|
| Service文件 | {gochat_stats.get('services', 0)} | {chatwoot_stats.get('services', 0)} |
|
|
| Test文件 | {gochat_stats.get('tests', 0)} | — |
|
|
| Stub Handler | {gochat_stats.get('stub_handlers', 0)} | — |
|
|
| 完整Handler | {gochat_stats.get('real_handlers', 0)} | — |
|
|
| Chatwoot资源数 | {len(chatwoot_res)} | — |
|
|
| GoChat资源数 | {len(gochat_res)} | — |
|
|
| 缺失资源 | {len(missing_res)} | — |
|
|
| 多余资源 | {len(extra_res)} | — |
|
|
|
|
## 二、缺失资源清单
|
|
|
|
以下{len(missing_res)}个资源在Chatwoot中存在但在GoChat中缺失:
|
|
|
|
""")
|
|
for res in sorted(missing_res):
|
|
f.write(f"- {res}\n")
|
|
|
|
f.write(f"""
|
|
## 三、缺失路由详情
|
|
|
|
以下{len(missing_in_gochat)}条路由在Chatwoot中存在但在GoChat中缺失(按资源分组):
|
|
|
|
""")
|
|
for resource in sorted(missing_by_resource.keys()):
|
|
routes = missing_by_resource[resource]
|
|
f.write(f"\n### {resource} — 缺失{len(routes)}条路由\n")
|
|
for method, orig_path in sorted(routes):
|
|
f.write(f"- {method} {orig_path}\n")
|
|
|
|
f.write(f"""
|
|
## 四、GoChat多余路由
|
|
|
|
以下{len(extra_in_gochat)}条路由在GoChat中存在但在Chatwoot中不存在(按资源分组):
|
|
|
|
""")
|
|
for resource in sorted(extra_by_resource.keys()):
|
|
routes = extra_by_resource[resource]
|
|
f.write(f"\n### {resource} — 多余{len(routes)}条路由\n")
|
|
for method, orig_path in sorted(routes):
|
|
f.write(f"- {method} {orig_path}\n")
|
|
|
|
f.write(f"""
|
|
## 五、优化计划
|
|
|
|
基于差距分析,建议按以下优先级推进实现:
|
|
|
|
### Phase 1: 路由补全 (P0 — 最高优先级)
|
|
|
|
当前覆盖率仅 {len(matched)/len(chatwoot_keys)*100:.1f}%,需补齐以下缺失路由:
|
|
|
|
""")
|
|
# Top 10 missing resources by route count
|
|
missing_res_count = defaultdict(int)
|
|
for key in missing_in_gochat:
|
|
method, norm_path = key
|
|
parts = norm_path.split('/')
|
|
if len(parts) >= 4:
|
|
resource = '/'.join(parts[:4])
|
|
else:
|
|
resource = norm_path
|
|
missing_res_count[resource] += 1
|
|
|
|
top_missing = sorted(missing_res_count.items(), key=lambda x: -x[1])[:15]
|
|
for resource, count in top_missing:
|
|
f.write(f"1. **{resource}** — 缺失{count}条路由\n")
|
|
|
|
f.write("""
|
|
### Phase 2: Service层补全
|
|
|
|
当前Service文件仅{}个(Chatwoot {}个),需补齐核心业务逻辑:
|
|
""".format(gochat_stats.get('services', 0), chatwoot_stats.get('services', 0)))
|
|
|
|
f.write("""
|
|
### Phase 3: Handler逻辑1:1对齐
|
|
|
|
当前Handler实现质量参差不齐({}个stub/{}个real),需逐模块验证:
|
|
- 错误码/响应结构必须匹配Chatwoot
|
|
- 副作用(事件触发、自动分配)必须一致
|
|
- 授权scope/过滤器/排序必须等效
|
|
""".format(gochat_stats.get('stub_handlers', 0), gochat_stats.get('real_handlers', 0)))
|
|
|
|
f.write("""
|
|
### Phase 4: 测试覆盖
|
|
|
|
当前测试文件{}个,需提升到834+覆盖所有核心场景:
|
|
- 每个CRUD操作的正常+异常路径
|
|
- 每个filter/sort/pagination组合
|
|
- 授权边界(角色/scope/权限)
|
|
""".format(gochat_stats.get('tests', 0)))
|
|
|
|
f.write("""
|
|
---
|
|
*报告基于 {now_str} 的代码状态生成,随着实现推进数据将动态更新。*
|
|
""")
|
|
|
|
print(f"Report generated: {report_name}")
|
|
return report_name
|
|
|
|
if __name__ == '__main__':
|
|
report = generate_report()
|
|
print(f"\n=== SUMMARY ===")
|
|
print(f"Report saved to: {report}") |