feat: 内容数据迁移至数据库,合规审查全链路打通

- 文章 HTML 存储从文件系统迁移至 articles 表,删除 releases 目录
- 合规审查从 DB 读取 HTML,审查结果写回 DB,通过后自动推进至待发布
- 新增 todayCount 筛选按钮,与系统概览统计数据一致
- 全屏预览修复:提升 z-index 超过侧边栏,添加退出全屏/关闭按钮
- 统一 '优化' → '审查' 命名,消除前后端术语不一致
- 调度器创作完成后自动触发审查(生成 → 审查 → 待发布)
- 清理旧备份/调试文件、过期大纲和研究笔记
This commit is contained in:
Yuzhiran Dev
2026-05-13 17:33:56 +08:00
parent bc6a302e59
commit 233e23016c
234 changed files with 5670 additions and 10651 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+281
View File
@@ -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())
+101
View File
@@ -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())
+110
View File
@@ -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())