feat: 内容数据迁移至数据库,合规审查全链路打通
- 文章 HTML 存储从文件系统迁移至 articles 表,删除 releases 目录 - 合规审查从 DB 读取 HTML,审查结果写回 DB,通过后自动推进至待发布 - 新增 todayCount 筛选按钮,与系统概览统计数据一致 - 全屏预览修复:提升 z-index 超过侧边栏,添加退出全屏/关闭按钮 - 统一 '优化' → '审查' 命名,消除前后端术语不一致 - 调度器创作完成后自动触发审查(生成 → 审查 → 待发布) - 清理旧备份/调试文件、过期大纲和研究笔记
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
宇之然平台 · 浏览器自动化测试
|
||||
- PC 视口 (1280x720)
|
||||
- H5 视口 (375x667)
|
||||
覆盖:登录、页面加载、导航、数据渲染、移动端响应式
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright, expect
|
||||
|
||||
BASE_URL = "http://localhost:8001"
|
||||
REPORT_DIR = Path("tests/reports")
|
||||
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CREDENTIALS = {"username": "admin", "password": "admin123"}
|
||||
|
||||
PAGES = [
|
||||
{"path": "/", "name": "仪表盘", "id_hint": "#app"},
|
||||
{"path": "/topics.html", "name": "选题管理"},
|
||||
{"path": "/calendar.html", "name": "内容日历"},
|
||||
{"path": "/metrics.html", "name": "数据分析"},
|
||||
{"path": "/assets.html", "name": "素材库"},
|
||||
{"path": "/tasks.html", "name": "创作任务"},
|
||||
{"path": "/platforms.html", "name": "平台配置"},
|
||||
{"path": "/admin.html", "name": "系统管理"},
|
||||
]
|
||||
|
||||
|
||||
class Tally:
|
||||
def __init__(self):
|
||||
self.passed = []
|
||||
self.failed = []
|
||||
self.skipped = []
|
||||
|
||||
def ok(self, case: str, detail=""):
|
||||
self.passed.append((case, detail))
|
||||
print(f" ✅ {case}")
|
||||
|
||||
def fail(self, case: str, detail=""):
|
||||
self.failed.append((case, detail))
|
||||
print(f" ❌ {case} — {detail}")
|
||||
|
||||
def skip(self, case: str, detail=""):
|
||||
self.skipped.append((case, detail))
|
||||
print(f" ⏭ {case} — {detail}")
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return len(self.passed) + len(self.failed) + len(self.skipped)
|
||||
|
||||
|
||||
def snap_path(name: str, view: str) -> str:
|
||||
ts = datetime.now().strftime("%H%M%S")
|
||||
return str(REPORT_DIR / f"{view}_{name}_{ts}.png")
|
||||
|
||||
|
||||
async def login(page, tally: Tally):
|
||||
await page.goto(f"{BASE_URL}/login.html", wait_until="networkidle")
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
try:
|
||||
inputs = page.locator("input.form-input")
|
||||
count = await inputs.count()
|
||||
if count >= 2:
|
||||
await inputs.nth(0).fill(CREDENTIALS["username"])
|
||||
await inputs.nth(1).fill(CREDENTIALS["password"])
|
||||
else:
|
||||
await page.fill("input[type=text]", CREDENTIALS["username"])
|
||||
await page.fill("input[type=password]", CREDENTIALS["password"])
|
||||
|
||||
await page.click("button.login-btn")
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
current = page.url
|
||||
if "login" not in current.lower():
|
||||
tally.ok("登录", "成功跳转至主页")
|
||||
return True
|
||||
else:
|
||||
err = await page.text_content("body")
|
||||
has_success = "登录成功" in (err or "")
|
||||
if has_success:
|
||||
await page.wait_for_timeout(3000)
|
||||
current = page.url
|
||||
if "login" not in current.lower():
|
||||
tally.ok("登录", "成功跳转(延迟后)")
|
||||
return True
|
||||
tally.fail("登录", f"仍停留在登录页")
|
||||
return False
|
||||
except Exception as e:
|
||||
tally.fail("登录", str(e))
|
||||
return False
|
||||
|
||||
|
||||
async def check_page_load(page, tally: Tally, page_info: dict):
|
||||
name = page_info["name"]
|
||||
path = page_info["path"]
|
||||
try:
|
||||
resp = await page.goto(f"{BASE_URL}{path}", wait_until="networkidle", timeout=15000)
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
status = resp.status if resp else 0
|
||||
if status >= 400:
|
||||
text = await page.text_content("body") or ""
|
||||
tally.fail(f"{name} 加载", f"HTTP {status}")
|
||||
return
|
||||
|
||||
content = await page.text_content("body") or ""
|
||||
|
||||
has_vue_error = "Vue警告" in content or "Failed to mount" in content or "Error" in content[:500]
|
||||
if has_vue_error:
|
||||
tally.fail(f"{name} 加载", "Vue 渲染异常")
|
||||
return
|
||||
|
||||
has_app = bool(re.search(r"导航|宇之然|dashboard|选题|内容", content[:200]))
|
||||
if has_app:
|
||||
tally.ok(f"{name} 加载", f"HTTP {status}")
|
||||
else:
|
||||
tally.fail(f"{name} 加载", "页面内容异常")
|
||||
except Exception as e:
|
||||
tally.fail(f"{name} 加载", str(e))
|
||||
|
||||
|
||||
async def check_api_data(page, tally: Tally, view: str):
|
||||
token = await page.evaluate("localStorage.getItem('authToken')")
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
routes = {
|
||||
"选题": ("/api/topics", None),
|
||||
"日历": ("/api/calendar?year=2026&month=5", None),
|
||||
"数据": ("/api/metrics/dashboard", None),
|
||||
"任务": ("/api/tasks", None),
|
||||
"素材": ("/api/assets", None),
|
||||
}
|
||||
for label, (path, _) in routes.items():
|
||||
try:
|
||||
resp = await page.request.get(f"{BASE_URL}{path}", headers=headers)
|
||||
body = await resp.json() if resp.ok else {}
|
||||
if resp.ok:
|
||||
tally.ok(f"{label} API ({view})", f"HTTP {resp.status}")
|
||||
elif resp.status == 404:
|
||||
tally.skip(f"{label} API ({view})", "路由不存在")
|
||||
elif resp.status == 401:
|
||||
tally.fail(f"{label} API ({view})", "无权限")
|
||||
else:
|
||||
tally.fail(f"{label} API ({view})", f"HTTP {resp.status}")
|
||||
except Exception as e:
|
||||
tally.fail(f"{label} API ({view})", str(e))
|
||||
|
||||
|
||||
async def check_navigation(page, tally: Tally, view: str, is_mobile: bool):
|
||||
try:
|
||||
await page.goto(f"{BASE_URL}/", wait_until="networkidle")
|
||||
await page.wait_for_timeout(800)
|
||||
|
||||
nav_links = [
|
||||
("选题", "topics.html"),
|
||||
("日历", "calendar.html"),
|
||||
("数据", "metrics.html"),
|
||||
]
|
||||
found = 0
|
||||
for label, target in nav_links:
|
||||
link = page.locator(f"a[href*='{target}'], [onclick*='{target}'], *:has-text('{label}')").first
|
||||
if await link.is_visible():
|
||||
found += 1
|
||||
|
||||
if not is_mobile:
|
||||
nav_links_extra = [
|
||||
("素材", "assets.html"),
|
||||
("任务", "tasks.html"),
|
||||
("平台", "platforms.html"),
|
||||
]
|
||||
for label, target in nav_links_extra:
|
||||
link = page.locator(f"a[href*='{target}'], [onclick*='{target}'], *:has-text('{label}')").first
|
||||
if await link.is_visible():
|
||||
found += 1
|
||||
|
||||
if found >= (2 if is_mobile else 3):
|
||||
tally.ok(f"导航系统 ({view})", f"发现 {found} 个导航链接")
|
||||
else:
|
||||
tally.fail(f"导航系统 ({view})", f"仅发现 {found} 个导航链接")
|
||||
except Exception as e:
|
||||
tally.fail(f"导航系统 ({view})", str(e))
|
||||
|
||||
|
||||
async def check_mobile_layout(page, tally: Tally):
|
||||
"""H5 专项:底部导航、内容撑满、无横向滚动"""
|
||||
await page.goto(f"{BASE_URL}/", wait_until="networkidle")
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
has_h_scroll = await page.evaluate("document.body.scrollWidth > document.documentElement.clientWidth + 5")
|
||||
if has_h_scroll:
|
||||
tally.fail("H5 无横向滚动", "存在横向滚动条")
|
||||
else:
|
||||
tally.ok("H5 无横向滚动")
|
||||
|
||||
bottom_nav = page.locator(".bottom-nav, .mobile-nav, nav:below(main), [class*=bottom], [class*=mobile]").first
|
||||
try:
|
||||
if await bottom_nav.is_visible():
|
||||
tally.ok("H5 底部导航", "底部导航可见")
|
||||
else:
|
||||
await page.screenshot(path=snap_path("h5_layout", "h5"))
|
||||
tally.skip("H5 底部导航", "未检测到典型底部导航,需人工确认")
|
||||
except Exception:
|
||||
tally.skip("H5 底部导航", "未找到底部导航元素")
|
||||
|
||||
|
||||
async def run_test(device_name: str, viewport, is_mobile: bool):
|
||||
tally = Tally()
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {device_name} 测试 @ {ts}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True, channel="chrome", args=["--no-sandbox"])
|
||||
context = await browser.new_context(
|
||||
viewport=viewport,
|
||||
is_mobile=is_mobile,
|
||||
device_scale_factor=2 if is_mobile else 1,
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
logged_in = await login(page, tally)
|
||||
|
||||
if not logged_in:
|
||||
await browser.close()
|
||||
return tally
|
||||
|
||||
for pg in PAGES:
|
||||
await check_page_load(page, tally, pg)
|
||||
|
||||
await check_navigation(page, tally, device_name, is_mobile)
|
||||
|
||||
await check_api_data(page, tally, device_name)
|
||||
|
||||
if is_mobile:
|
||||
await check_mobile_layout(page, tally)
|
||||
|
||||
await page.screenshot(path=snap_path("final", device_name.lower().replace(" ", "_")))
|
||||
await browser.close()
|
||||
|
||||
return tally
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"{'='*60}")
|
||||
print(f" 宇之然平台 · 浏览器自动化测试")
|
||||
print(f" {BASE_URL}")
|
||||
print(f" 报告: {REPORT_DIR.resolve()}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
pc_tally = await run_test("PC (1280x720)", {"width": 1280, "height": 720}, is_mobile=False)
|
||||
h5_tally = await run_test("H5 (375x667)", {"width": 375, "height": 667}, is_mobile=True)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" 测试汇总")
|
||||
print(f"{'='*60}")
|
||||
for label, t in [("PC", pc_tally), ("H5", h5_tally)]:
|
||||
print(f" {label}: ✅ {len(t.passed)} ❌ {len(t.failed)} ⏭ {len(t.skipped)} / 总计 {t.total}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
overall_fail = len(pc_tally.failed) + len(h5_tally.failed)
|
||||
if overall_fail > 0:
|
||||
print(f"\n❌ {overall_fail} 个失败用例:")
|
||||
for label, t in [("PC", pc_tally), ("H5", h5_tally)]:
|
||||
for case, detail in t.failed:
|
||||
print(f" [{label}] {case}: {detail}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"\n✅ 全部通过!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
侧边栏/底部导航 点击跳转专项测试
|
||||
统一使用 native click 避免 Playwright strict hit-test 误判
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
BASE_URL = "http://localhost:8001"
|
||||
CREDENTIALS = {"username": "admin", "password": "admin123"}
|
||||
|
||||
NAV_ITEMS = [
|
||||
("系统概览", "/", ""),
|
||||
("选题管理", "topics.html", ""),
|
||||
("数据分析", "metrics.html", "more"),
|
||||
("内容日历", "calendar.html", ""),
|
||||
("素材库", "assets.html", "more"),
|
||||
("创作任务", "tasks.html", ""),
|
||||
("平台配置", "platforms.html", "more"),
|
||||
("系统日志", "logs.html", "more"),
|
||||
("系统管理", "admin.html", "more"),
|
||||
]
|
||||
|
||||
|
||||
def native_click(page, selector):
|
||||
return page.evaluate(f"""() => {{
|
||||
const el = document.querySelector('{selector}');
|
||||
if (!el) return false;
|
||||
el.click();
|
||||
return true;
|
||||
}}""")
|
||||
|
||||
|
||||
async def run_nav_test(viewport, label, is_mobile):
|
||||
print(f"\n--- {label} ---")
|
||||
ok, fail = 0, 0
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True, channel="chrome", args=["--no-sandbox"])
|
||||
page = await browser.new_page(viewport=viewport)
|
||||
|
||||
await page.goto(f"{BASE_URL}/login.html", wait_until="networkidle")
|
||||
await page.wait_for_timeout(500)
|
||||
await page.locator("input.form-input").nth(0).fill(CREDENTIALS["username"])
|
||||
await page.locator("input.form-input").nth(1).fill(CREDENTIALS["password"])
|
||||
await page.click("button.login-btn")
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
for name, target, more_group in NAV_ITEMS:
|
||||
try:
|
||||
if is_mobile:
|
||||
sel = f'.mobile-nav-btn[data-page="{target}"]'
|
||||
clicked = await native_click(page, sel)
|
||||
|
||||
if not clicked:
|
||||
# 打开更多面板后再点
|
||||
more_sel = "#mobile-more-btn"
|
||||
await native_click(page, more_sel)
|
||||
await page.wait_for_timeout(400)
|
||||
sel = f'.sheet-item[data-page="{target}"]'
|
||||
clicked = await native_click(page, sel)
|
||||
|
||||
if not clicked:
|
||||
print(f" ⏭ {name}: 按钮未找到")
|
||||
continue
|
||||
else:
|
||||
sel = f'.sidebar-btn[data-page="{target}"]'
|
||||
ok2 = await native_click(page, sel)
|
||||
if not ok2:
|
||||
print(f" ⏭ {name}: 按钮未找到")
|
||||
continue
|
||||
|
||||
await page.wait_for_timeout(1500)
|
||||
matched = target in page.url if target != "/" else page.url.endswith("/") or "index" in page.url
|
||||
if matched:
|
||||
print(f" ✅ {name} -> {page.url}")
|
||||
ok += 1
|
||||
else:
|
||||
print(f" ❌ {name} -> {page.url}")
|
||||
fail += 1
|
||||
except Exception as e:
|
||||
print(f" ❌ {name}: {e}")
|
||||
fail += 1
|
||||
|
||||
await browser.close()
|
||||
return ok, fail
|
||||
|
||||
|
||||
async def main():
|
||||
pc_ok, pc_fail = await run_nav_test({"width": 1280, "height": 720}, "PC 侧边栏", False)
|
||||
h5_ok, h5_fail = await run_nav_test({"width": 375, "height": 667}, "H5 底部导航", True)
|
||||
total_ok = pc_ok + h5_ok
|
||||
total_fail = pc_fail + h5_fail
|
||||
print(f"\n{'='*50}")
|
||||
print(f" 导航测试: PC ✅{pc_ok}❌{pc_fail} | H5 ✅{h5_ok}❌{h5_fail} | 总计 ✅{total_ok}❌{total_fail}")
|
||||
print(f"{'='*50}")
|
||||
if total_fail:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""安全与错误处理测试"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
BASE_URL = "http://localhost:8001"
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
os.system("pip install requests -q")
|
||||
import requests
|
||||
|
||||
def get_token():
|
||||
r = requests.post(f"{BASE_URL}/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
return r.json()["token"] if r.status_code == 200 else None
|
||||
|
||||
def h(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
def check(name, condition, details=""):
|
||||
icon = "✅" if condition else "❌"
|
||||
print(f" {icon} {name}{' (' + details + ')' if details else ''}")
|
||||
return condition
|
||||
|
||||
def section(name):
|
||||
print(f"\n{'='*50}")
|
||||
print(f" {name}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("宇之然平台 - 安全与错误处理测试")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
section("🔐 认证安全")
|
||||
r = requests.get(f"{BASE_URL}/api/topics")
|
||||
results.append(check("无 token 请求被拒绝", r.status_code == 401 or r.status_code == 403, f"HTTP {r.status_code}"))
|
||||
|
||||
r = requests.get(f"{BASE_URL}/api/topics", headers={"Authorization": "Bearer invalid_token"})
|
||||
results.append(check("无效 token 被拒绝", r.status_code == 401 or r.status_code == 403, f"HTTP {r.status_code}"))
|
||||
|
||||
r = requests.get(f"{BASE_URL}/api/topics", headers={"Authorization": ""})
|
||||
results.append(check("空 token 被拒绝", r.status_code == 401 or r.status_code == 403, f"HTTP {r.status_code}"))
|
||||
|
||||
r = requests.post(f"{BASE_URL}/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
results.append(check("错误密码登录失败", r.status_code == 401, f"HTTP {r.status_code}"))
|
||||
|
||||
r = requests.post(f"{BASE_URL}/api/auth/login", json={"username": "nonexistent", "password": "test"})
|
||||
results.append(check("不存在的用户登录失败", r.status_code == 401, f"HTTP {r.status_code}"))
|
||||
|
||||
section("🛡️ CORS 安全")
|
||||
r = requests.get(f"{BASE_URL}/api/system/status", headers=h(get_token()))
|
||||
cors_origin = r.headers.get("access-control-allow-origin", "")
|
||||
results.append(check("CORS 不返回通配符 *", cors_origin != "*", f"Origin: {cors_origin or '(none)'}"))
|
||||
|
||||
section("⚠️ 错误处理")
|
||||
r = requests.get(f"{BASE_URL}/api/nonexistent", headers=h(get_token()))
|
||||
results.append(check("不存在路由返回 404", r.status_code == 404, f"HTTP {r.status_code}"))
|
||||
|
||||
r = requests.get(f"{BASE_URL}/api/topics/99999", headers=h(get_token()))
|
||||
results.append(check("不存在资源返回 404", r.status_code == 404, f"HTTP {r.status_code}"))
|
||||
|
||||
r = requests.post(f"{BASE_URL}/api/topics", headers=h(get_token()), json={})
|
||||
results.append(check("空数据创建返回验证错误", r.status_code == 422, f"HTTP {r.status_code}"))
|
||||
|
||||
r = requests.post(f"{BASE_URL}/api/topics", headers=h(get_token()), json={"title": ""})
|
||||
results.append(check("空标题返回验证错误", r.status_code == 422, f"HTTP {r.status_code}"))
|
||||
|
||||
section("📄 内容安全")
|
||||
token = get_token()
|
||||
r = requests.post(f"{BASE_URL}/api/topics", headers=h(token), json={
|
||||
"title": "<script>alert('xss')</script>测试",
|
||||
"field_id": 1,
|
||||
"content": "<script>alert('xss')</script>"
|
||||
})
|
||||
results.append(check("含 HTML 的内容可创建", r.ok, f"HTTP {r.status_code}"))
|
||||
if r.ok:
|
||||
new_id = r.json().get("id")
|
||||
if new_id:
|
||||
r2 = requests.delete(f"{BASE_URL}/api/topics/{new_id}", headers=h(token))
|
||||
results.append(check("清理测试数据", r2.ok or r2.status_code == 404))
|
||||
|
||||
section("🔄 幂等性")
|
||||
payload = {"title": "幂等测试", "field_id": 1}
|
||||
r1 = requests.post(f"{BASE_URL}/api/topics", headers=h(token), json=payload)
|
||||
r2 = requests.post(f"{BASE_URL}/api/topics", headers=h(token), json=payload)
|
||||
results.append(check("重复创建不报错", r1.ok and r2.ok, f"第1次:{r1.status_code} 第2次:{r2.status_code}"))
|
||||
if r1.ok:
|
||||
del_id = r1.json().get("id")
|
||||
if del_id:
|
||||
requests.delete(f"{BASE_URL}/api/topics/{del_id}", headers=h(token))
|
||||
if r2.ok and r2.json().get("id") != (r1.json().get("id") if r1.ok else None):
|
||||
del_id2 = r2.json().get("id")
|
||||
if del_id2:
|
||||
requests.delete(f"{BASE_URL}/api/topics/{del_id2}", headers=h(token))
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
print(f"测试结果: {passed}/{total} 通过 ({passed*100//total}%)")
|
||||
print("=" * 60)
|
||||
return 0 if passed == total else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||