feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化

Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列
测试: tests/test_phase_upgrades.py 97项全覆盖
CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式
修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
Yuzhiran Dev
2026-05-17 06:56:53 +08:00
parent 301dc3e438
commit 9c37c9a574
45 changed files with 3707 additions and 1366 deletions
+149
View File
@@ -0,0 +1,149 @@
"""全面测试:新增功能验证"""
import uvicorn, time, threading, requests, json, os, sys
from pathlib import Path
# Setup
os.environ.setdefault("DATABASE_URL", "sqlite:///../../automation/data/test.db")
os.environ.setdefault("USE_POSTGRES", "false")
backend_dir = str(Path(__file__).parent.parent / "platform" / "backend")
sys.path.insert(0, backend_dir)
os.chdir(backend_dir)
results = {"pass": 0, "fail": 0}
def test(name, ok, detail=""):
status = chr(10003) if ok else chr(10007)
results["pass" if ok else "fail"] += 1
print(f" {status} {name}")
if detail:
print(f" {detail}")
# 1. Python compile check
print("=== 1. Python 编译检查 ===")
files = [
"platform/backend/app/models.py",
"platform/backend/app/api/collector_mgmt.py",
"platform/backend/app/core/nvidia_client.py",
"platform/backend/app/core/scheduler.py",
"platform/backend/app/initial_data.py",
"platform/backend/app/main.py",
"scripts/collector.py",
"scripts/db_helper.py",
]
root = Path(__file__).parent.parent
for f in files:
try:
src = (root / f).read_text()
compile(src, f, "exec")
test(f" {f}", True)
except SyntaxError as e:
test(f" {f}", False, str(e))
# 2. Start server
print("\n=== 2. API 服务启动 ===")
def start_server():
uvicorn.run("app.main:app", host="127.0.0.1", port=18503, log_level="error")
t = threading.Thread(target=start_server, daemon=True)
t.start()
time.sleep(4)
base = "http://127.0.0.1:18503"
try:
r = requests.get(f"{base}/api/system/status", timeout=5)
test("服务可访问", r.status_code == 200)
except Exception as e:
test("服务可访问", False, str(e))
exit(1)
# 3. Login
print("\n=== 3. 登录认证 ===")
r = requests.post(f"{base}/api/auth/login", json={"username": "admin", "password": "admin123"}, timeout=5)
token = r.json().get("token", "") if r.status_code == 200 else ""
test("管理员登录", r.status_code == 200 and bool(token))
if not token:
exit(1)
auth = {"Authorization": f"Bearer {token}"}
# 4. System status
print("\n=== 4. 系统状态 ===")
r = requests.get(f"{base}/api/system/status", headers=auth, timeout=5)
test("GET /api/system/status", r.status_code == 200 and "stats" in r.json())
# 5. Collector categories CRUD
print("\n=== 5. 采集类别 CRUD ===")
r = requests.get(f"{base}/api/admin/collector/categories", headers=auth, timeout=5)
test("GET 类别列表", r.status_code == 200)
initial_count = len(r.json())
test(f"初始类别数>0", initial_count > 0, f"{initial_count}")
r = requests.post(f"{base}/api/admin/collector/categories", headers=auth,
json={"name": "新类别Z", "search_query": "新类别 2026", "sort_order": 99}, timeout=5)
test("POST 新增类别", r.status_code == 201)
new_id = r.json().get("id")
test("返回ID", bool(new_id))
r = requests.put(f"{base}/api/admin/collector/categories/{new_id}", headers=auth,
json={"description": "测试编辑"}, timeout=5)
test("PUT 编辑类别", r.status_code == 200 and r.json().get("description") == "测试编辑")
r = requests.delete(f"{base}/api/admin/collector/categories/{new_id}", headers=auth, timeout=5)
test("DELETE 删除类别", r.status_code == 200)
r = requests.get(f"{base}/api/admin/collector/categories", headers=auth, timeout=5)
test("删除后列表数恢复", len(r.json()) == initial_count)
# 6. Collector sources CRUD
print("\n=== 6. 信息源 CRUD ===")
r = requests.get(f"{base}/api/admin/collector/sources", headers=auth, timeout=5)
test("GET 源列表", r.status_code == 200)
src_count = len(r.json())
test(f"初始源数>0", src_count > 0, f"{src_count}")
r = requests.post(f"{base}/api/admin/collector/sources", headers=auth,
json={"name": "新源Z", "source_type": "web_search", "query": "新查询 2026"}, timeout=5)
test("POST 新增源", r.status_code == 201)
new_src_id = r.json().get("id")
r = requests.put(f"{base}/api/admin/collector/sources/{new_src_id}", headers=auth,
json={"focus": "测试领域"}, timeout=5)
test("PUT 编辑源", r.status_code == 200)
r = requests.delete(f"{base}/api/admin/collector/sources/{new_src_id}", headers=auth, timeout=5)
test("DELETE 删除源", r.status_code == 200)
# 7. LLM provider config
print("\n=== 7. LLM多供应商 ===")
sys.path.insert(0, str(root / "platform" / "backend"))
from app.core.nvidia_client import PROVIDERS, _ACTIVE_PROVIDER, _get_provider_config
test("默认供应商", _ACTIVE_PROVIDER == "opencode-go")
test("opencode-go已配置", "opencode-go" in PROVIDERS)
test("nvidia备用存在", "nvidia" in PROVIDERS)
cfg = PROVIDERS["opencode-go"]
test("opencode-go模型", cfg["model"] == "deepseek-v4-flash")
test("opencode-go URL非空", bool(cfg["base_url"]))
test("opencode-go Key非空", bool(cfg["api_key"]))
# 8. Collector DB loading
print("\n=== 8. 采集器DB加载 ===")
sys.path.insert(0, str(root / "scripts"))
from collector import SustainabilityCollector
collector = SustainabilityCollector()
cats = collector.config.get("sustainability_categories", [])
test("从DB加载类别", len(cats) > 0, f"{len(cats)}个类别: {cats}")
test("从DB加载源", len(collector.sources) > 0, f"{len(collector.sources)}个源")
# 9. API auth protection
print("\n=== 9. API鉴权保护 ===")
r = requests.get(f"{base}/api/admin/collector/categories", timeout=5)
test("未认证访问被拒绝", r.status_code != 200, f"实际状态码: {r.status_code}")
r = requests.post(f"{base}/api/admin/collector/sources", timeout=5,
json={"name": "x", "source_type": "rss"})
test("未认证POST被拒绝", r.status_code != 200, f"实际状态码: {r.status_code}")
# Summary
total = results["pass"] + results["fail"]
print(f"\n{'='*40}")
print(f"总计: {results['pass']}/{total} 通过, {results['fail']} 失败")
if results["fail"] > 0:
exit(1)
+258
View File
@@ -0,0 +1,258 @@
"""四阶段升级全面测试"""
import uvicorn, time, threading, requests, json, os, sys
from pathlib import Path
os.environ.setdefault("USE_POSTGRES", "false")
backend_dir = str(Path(__file__).parent.parent / "platform" / "backend")
sys.path.insert(0, backend_dir)
os.chdir(backend_dir)
results = {"pass": 0, "fail": 0}
def test(name, ok, detail=""):
status = chr(10003) if ok else chr(10007)
results["pass" if ok else "fail"] += 1
print(f" {status} {name}")
if detail: print(f" {detail}")
# ===== 0. 编译检查 =====
print("\n====== 阶段 0: 编译检查 ======")
root = Path(__file__).parent.parent
files = [
"platform/backend/app/models.py", "platform/backend/app/api/auth.py",
"platform/backend/app/api/topics.py", "platform/backend/app/api/articles.py",
"platform/backend/app/api/calendar.py", "platform/backend/app/api/metrics.py",
"platform/backend/app/api/publishing.py", "platform/backend/app/api/system.py",
"platform/backend/app/api/admin.py", "platform/backend/app/api/tasks.py",
"platform/backend/app/schemas.py", "platform/backend/app/database.py",
"platform/backend/app/initial_data.py", "platform/backend/app/main.py",
"platform/backend/app/core/nvidia_client.py", "platform/backend/app/core/scheduler.py",
"scripts/collector.py", "scripts/sync_metrics.py", "scripts/creator.py",
]
for f in files:
try:
src = (root / f).read_text()
compile(src, f, "exec")
test(f"编译 {Path(f).name}", True)
except SyntaxError as e:
test(f"编译 {Path(f).name}", False, str(e))
# ===== 1. 启动服务 =====
print("\n====== 阶段 0: API 服务启动 ======")
def start_server():
uvicorn.run("app.main:app", host="127.0.0.1", port=18504, log_level="error")
t = threading.Thread(target=start_server, daemon=True)
t.start()
time.sleep(4)
base = "http://127.0.0.1:18504"
try:
r = requests.get(f"{base}/api/system/status", timeout=5)
test("服务可访问", r.status_code == 200)
except Exception as e:
test("服务可访问", False, str(e))
exit(1)
r = requests.post(f"{base}/api/auth/login", json={"username": "admin", "password": "admin123"}, timeout=5)
token = r.json().get("token", "") if r.status_code == 200 else ""
test("管理员登录", r.status_code == 200 and bool(token))
if not token:
exit(1)
auth = {"Authorization": f"Bearer {token}"}
# Decode JWT to check org_id
import jwt
payload = jwt.decode(token, options={"verify_signature": False})
test("JWT含org_id", payload.get("org_id") == "default", f"org_id={payload.get('org_id')}")
test("JWT含username", payload.get("username") == "admin")
test("JWT含role", payload.get("role") == "admin")
# ===== Phase 1.1: 配图集成 =====
print("\n====== Phase 1.1: 配图集成 ======")
r = requests.get(f"{base}/api/topics", headers=auth, timeout=5)
test("选题列表可访问", r.status_code == 200)
topics = r.json()
test("有选题数据", len(topics) > 0, f"{len(topics)}")
if topics:
tid = topics[0]["id"]
r = requests.get(f"{base}/api/articles/{tid}/images", headers=auth, timeout=5)
test("配图API可访问", r.status_code == 200)
test("配图返回dict", isinstance(r.json(), dict))
r = requests.get(f"{base}/api/articles/{tid}/preview", headers=auth, timeout=5)
test("预览API可访问", r.status_code == 200 or r.status_code == 404)
# ===== Phase 1.3: 前端三态 =====
print("\n====== Phase 1.3: 前端三态UX ======")
pages = ["index.html", "topics.html", "metrics.html", "calendar.html",
"articles.html", "tasks.html", "admin.html", "users.html"]
for page in pages:
p = root / "platform" / "frontend" / page
html = p.read_text(encoding="utf-8")
has_loading = "loading" in html.lower()
has_empty = "empty" in html.lower() or "暂无" in html
has_error = "error" in html.lower() or "失败" in html or "catch" in html
ok = has_loading and has_empty and has_error
test(f"{page}: 三态 ({'' if ok else ''})", ok,
f"loading={has_loading} empty={has_empty} error={has_error}")
# ===== Phase 2.1: 多平台发布 =====
print("\n====== Phase 2.1: 多平台发布 ======")
r = requests.post(f"{base}/api/topics", headers=auth, json={
"title": f"测试选题_{int(time.time())}", "tags": ["test"]
}, timeout=5)
test("创建测试选题", r.status_code == 200)
new_tid = r.json().get("id", "")
if new_tid:
r = requests.put(f"{base}/api/topics/{new_tid}", headers=auth,
json={"status": "ready"}, timeout=5)
test("设置待发布状态", r.status_code == 200)
r = requests.post(f"{base}/api/publishing/create", headers=auth, json={
"topic_id": new_tid,
"platforms": ["zhihu", "wechat"]
}, timeout=5)
test("多平台发布API", r.status_code == 200)
data = r.json()
test("发布返回ok", data.get("ok") == True)
test("发布结果含平台列表", len(data.get("results", [])) > 0)
if data.get("results"):
test("第一平台发布成功", data["results"][0]["status"] == "success")
# ===== Phase 2.2: 日历关联 =====
print("\n====== Phase 2.2: 日历选题关联 ======")
r = requests.get(f"{base}/api/calendar", headers=auth, params={"year": 2026, "month": 5}, timeout=5)
test("日历API可访问", r.status_code == 200)
entries = r.json()
if entries:
e = entries[0]
test("含topic_status字段", "topic_status" in e, f"值={e.get('topic_status')}")
test("含platform_icon字段", "platform_icon" in e, f"值={e.get('platform_icon')}")
# ===== Phase 2.3: SVG图标 + H5 =====
print("\n====== Phase 2.3: SVG图标 + H5响应式 ======")
icon_js = (root / "platform" / "frontend" / "icon-components.js").read_text(encoding="utf-8")
import re
icon_names = set(re.findall(r'Icon[A-Z][a-zA-Z]+', icon_js))
test("icon-components.js注册图标", len(icon_names) >= 20, f"{len(icon_names)}个图标: {sorted(icon_names)[:10]}...")
test("含IconCheck组件", "IconCheck" in icon_js)
test("含IconClose组件", "IconClose" in icon_js)
test("含IconSetting组件", "IconSetting" in icon_js)
test("含IconDashboard组件", "IconDashboard" in icon_js)
mobile_pages = ["topics.html", "metrics.html", "admin.html", "users.html", "articles.html"]
for page in mobile_pages:
html = (root / "platform" / "frontend" / page).read_text()
has_card_list = "card-list" in html or "card_list" in html
has_media = "@media" in html
test(f"{page}: H5卡片+响应式", has_card_list and has_media)
# ===== Phase 3.1: 指标同步 =====
print("\n====== Phase 3.1: 指标同步 ======")
sync_py = (root / "scripts" / "sync_metrics.py").read_text()
test("sync_metrics.py 存在", True)
test("含估计算法", "compliance_score" in sync_py or "estimate" in sync_py or "platform_multiplier" in sync_py)
test("含upsert逻辑", "upsert" in sync_py.lower() or "on_conflict" in sync_py or "existing" in sync_py)
scheduler_py = (root / "platform" / "backend" / "app" / "core" / "scheduler.py").read_text()
test("调度含scheduled_metrics_sync", "scheduled_metrics_sync" in scheduler_py)
r = requests.get(f"{base}/api/metrics/entries", headers=auth, timeout=5)
test("指标API可访问", r.status_code == 200)
test("指标列表是list", isinstance(r.json(), list))
# ===== Phase 3.2: Chart.js 看板 =====
print("\n====== Phase 3.2: Chart.js 数据看板 ======")
chart_js = root / "platform" / "frontend" / "chart.umd.min.js"
test("chart.umd.min.js 存在", chart_js.exists())
if chart_js.exists():
test("chart文件非空", chart_js.stat().st_size > 10000, f"大小={chart_js.stat().st_size}B")
r = requests.get(f"{base}/api/metrics/dashboard", headers=auth, timeout=5)
test("数据看板API", r.status_code == 200)
db_data = r.json()
test("含total_topics", "total_topics" in db_data, f"值={db_data.get('total_topics')}")
test("含topics_by_status", "topics_by_status" in db_data)
test("含total_views", "total_views" in db_data)
test("含total_likes", "total_likes" in db_data)
test("含avg_engagement_rate", "avg_engagement_rate" in db_data)
test("含top_topics", "top_topics" in db_data)
test("含recent_metrics", "recent_metrics" in db_data)
r = requests.get(f"{base}/api/metrics/trend", headers=auth, timeout=5)
test("趋势API", r.status_code == 200, f"返回{len(r.json())}")
r = requests.get(f"{base}/api/metrics/by-platform", headers=auth, timeout=5)
test("平台对比API", r.status_code == 200, f"返回{len(r.json())}个平台")
metrics_html = (root / "platform" / "frontend" / "metrics.html").read_text()
test("metrics.html含Chart.js引用", "chart.umd.min.js" in metrics_html or "chart.js" in metrics_html.lower())
test("含折线图初始化", "new Chart" in metrics_html or "Chart(" in metrics_html)
test("含环形图初始化", "doughnut" in metrics_html or "doughnut" in metrics_html.lower())
test("含柱状图初始化", "bar" in metrics_html)
# ===== Phase 4: 多租户 =====
print("\n====== Phase 4: 多租户隔离 ======")
# 4a. 模型有org_id
models_py = (root / "platform" / "backend" / "app" / "models.py").read_text()
test("User模型含org_id", "org_id" in models_py)
test("Topic模型含org_id", "org_id" in models_py)
# 4b. JWT含org_id (已在前面验证)
# 4c. 选题创建继承org
if new_tid:
r = requests.get(f"{base}/api/topics/{new_tid}", headers=auth, timeout=5)
test("新选题含org_id", r.json().get("org_id") == "default" or "org_id" in r.json())
# 4d. 组织管理CRUD
r = requests.get(f"{base}/api/admin/orgs", headers=auth, timeout=5)
test("组织列表API", r.status_code == 200)
orgs = r.json()
test("默认组织存在", any(o.get("org_id") == "default" for o in orgs))
org_id = f"test_org_{int(time.time())}"
r = requests.post(f"{base}/api/admin/orgs", headers=auth, json={
"org_id": org_id, "name": "测试组织", "description": "自动化测试"
}, timeout=5)
test("创建组织", r.status_code == 200, f"org_id={org_id}")
r = requests.put(f"{base}/api/admin/orgs/{org_id}", headers=auth,
json={"name": "测试组织(已更新)"}, timeout=5)
test("更新组织", r.status_code == 200)
r = requests.delete(f"{base}/api/admin/orgs/{org_id}", headers=auth, timeout=5)
test("删除组织", r.status_code == 200)
# 4e. users.html org_id列
users_html = (root / "platform" / "frontend" / "users.html").read_text()
test("users.html含组织列", "org_id" in users_html)
test("users.html表含组织头", "组织" in users_html)
# 4f. admin.html组织管理标签
admin_html = (root / "platform" / "frontend" / "admin.html").read_text()
test("admin.html含组织标签", "组织管理" in admin_html)
test("admin.html含组织CRUD函数", "loadOrgs" in admin_html)
# 4g. 数据库迁移
db_py = (root / "platform" / "backend" / "app" / "database.py").read_text()
test("database.py含org_id迁移", "users.*org_id" in db_py.replace(" ", "").replace("\n", "") or
("org_id" in db_py and "ALTER TABLE" in db_py))
# ===== 统一CSS检查 =====
print("\n====== 统一CSS检查 ======")
theme_css = (root / "platform" / "frontend" / "theme-modern.css").read_text()
test("theme-modern.css存在", True)
test("含响应式@media 768px", "@media (max-width: 768px)" in theme_css)
test("含统一卡片类.card", ".card" in theme_css)
test("含统一布局.main-content", ".main-content" in theme_css)
test("含统一.page-header", ".page-header" in theme_css)
test("含空状态.empty-state", ".empty-state" in theme_css)
test("含加载状态.loading-state", ".loading-state" in theme_css)
mobile_patterns = ["mobile-card", "padding-bottom: 80px", "el-dialog", "el-button"]
present = sum(1 for p in mobile_patterns if p in theme_css)
test(f"H5触控优化 ({present}/{len(mobile_patterns)})", present >= 2, f"含: mobile-card/padding-bottom/el-dialog/el-button")
# ===== 总结 =====
total = results["pass"] + results["fail"]
print(f"\n{'='*50}")
print(f"阶段测试完成: {results['pass']}/{total} 通过, {results['fail']} 失败")
if results["fail"] > 0:
exit(1)