Compare commits

..
15 changed files with 439 additions and 44 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# qihuaiflow minimal CI (Gitea Actions)
#
# NOTE: This workflow is DORMANT until a Gitea act_runner is registered with
# this repo. The active gate today is the host-side pre-push hook
# (scripts/git-hooks/pre-push) + scripts/ci_gate.py, because our deployment
# model keeps the app running in a long-lived `qihu-aiflow` container on the
# docker host rather than building it fresh in CI.
#
# To activate later:
# 1. Install act_runner on the docker host (sharing the docker socket).
# 2. Register it against this repo with label `ubuntu-latest`.
# 3. The runner can then `docker exec qihu-aiflow ...` to reach the app.
#
# Until then, the pre-push hook already blocks broken pushes.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Backend pytest
run: docker exec qihu-aiflow bash -c 'cd /app/backend/python && .venv/bin/python -m pytest -c pytest.ini -q'
- name: Frontend vitest
run: docker exec qihu-aiflow bash -c 'cd /app/frontend && npx vitest run --reporter=dot'
+12 -8
View File
@@ -3,19 +3,23 @@
# 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始) # 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始)
# 环境标签: prod = 正式版 | test = 测试版 # 环境标签: prod = 正式版 | test = 测试版
VERSION=v2026.07.18.1 VERSION=v2026.07.18.5
ENV=prod ENV=prod
DATE=2026-07-18 DATE=2026-07-18
BASE_IMAGE=qihu-aiflow:with-deps BASE_IMAGE=qihu-aiflow:with-deps
BACKUP_IMAGE=qihu-aiflow:backup-20260718-001 BACKUP_IMAGE=qihu-aiflow:backup-20260718-005
## 本版本变更 (v2026.07.18.1 / prod) ## 本版本变更 (v2026.07.18.5 / prod)
- Phase0 工程纪律止血 启动 - Phase0 收官:新增「最小 CI 门禁」
- 新增「部署前镜像备份 SOP」:scripts/backup_before_deploy.py,每次部署前自动提交递增序号镜像快照 - scripts/ci_gate.py:统一测试网关,容器内跑后端 pytest + 前端 vitest,自动判定本地 docker 或经 ssh_helper 远程执行,任一失败即非零退出阻断发布
- 统一版本号规范 vYYYY.MM.DD.N + prod/test 标签(取代旧 -recover 标签) - scripts/git-hooks/pre-push:纯 bash 的 git pre-push 钩子,push 前在容器内跑两套测试,失败即阻断 push(已安装到 .git/hooks)
- 引入后端 pytest / 前端 vitest 测试基建,作为迭代式重构的安全网 - .gitea/workflows/ci.yml:Gitea Actions 最小 CI 模板(当前无 act_runner,处于 dormant;钩子已先行拦截坏推送)
- 固化反复问题的回归用例 R1/R2/R3 - 测试安全网现状:后端 9 passed(smoke/market/sms + R1/R2/R3),前端 4 passed(datetime 时区格式化)
## 历史 ## 历史
- v2026.07.18.4 (prod):前端 vitest 基建落地(4 passed,datetime 时区格式化)。
- v2026.07.18.3 (prod):回归用例 R1/R2/R3(9 passed)— 微信Bot截断、技能/MCP覆盖规则、媒体生成超时回写。
- v2026.07.18.2 (prod):后端 pytest 基建落地(6 passed)— 冒烟/市场14类/短信宝MD5。
- v2026.07.18.1 (prod):Phase0 启动 — 部署前镜像备份 SOP + 版本号规范 + 引入测试基建。
- 旧方案:镜像曾用 AIFlow v2.0.1 / v2026.07.14 / v2026.07.14-nav;git 曾用 v2026.07.12-recover / v2026.07.12-ui-recover / v2026.07.13-recover。自 v2026.07.18.1 起统一为 vYYYY.MM.DD.N + prod/test,便于正式版推广后与测试版做环境切割。 - 旧方案:镜像曾用 AIFlow v2.0.1 / v2026.07.14 / v2026.07.14-nav;git 曾用 v2026.07.12-recover / v2026.07.12-ui-recover / v2026.07.13-recover。自 v2026.07.18.1 起统一为 vYYYY.MM.DD.N + prod/test,便于正式版推广后与测试版做环境切割。
- 历史功能:AI聊天(real API + tool-call function calling)、知识库文件存储 + AI 图谱、频道二维码、邮件/短信配置 + AI 生成、支付配置、平台驾驶舱(token 累计 + 审计/操作日志)、Agent 资源 API、evolution pipeline 自动恢复、全局默认模型、广告位图片上传 + AI 绘图、应用市场安装/卸载/搜索/去重、资产文件类型分类 + 在线预览。迁移 001-015。 - 历史功能:AI聊天(real API + tool-call function calling)、知识库文件存储 + AI 图谱、频道二维码、邮件/短信配置 + AI 生成、支付配置、平台驾驶舱(token 累计 + 审计/操作日志)、Agent 资源 API、evolution pipeline 自动恢复、全局默认模型、广告位图片上传 + AI 绘图、应用市场安装/卸载/搜索/去重、资产文件类型分类 + 在线预览。迁移 001-015。
+12
View File
@@ -0,0 +1,12 @@
"""Pytest 根配置:保证测试运行时 backend 包根目录在 sys.path 中。
运行方式(容器内,使用项目 venv):
cd /app/backend/python
/app/backend/python/.venv/bin/python -m pytest
"""
import os
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
+11
View File
@@ -0,0 +1,11 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
addopts = -q --strict-markers
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
+4
View File
@@ -0,0 +1,4 @@
# Phase 0 测试基建 — 开发/测试依赖(不进入生产 requirements.txt)
pytest==8.3.4
pytest-asyncio==0.24.0
httpx==0.27.2
View File
Whitespace-only changes.
+20
View File
@@ -0,0 +1,20 @@
"""应用市场场景分类器回归(稳定模块,无外部依赖)。
仅校验分类法结构本身不被破坏;不调用 _classify_scene(其内部会请求 LLM)。
分类法是前端筛选/徽章的数据源,结构回归会导致全站分类错乱。
"""
from app.api.market import MARKET_SCENES
def test_market_scenes_is_14_flat():
assert len(MARKET_SCENES) == 14
def test_market_scenes_unique_ids_and_required_keys():
ids = [s["id"] for s in MARKET_SCENES]
assert len(set(ids)) == 14 # 无重复 id
for s in MARKET_SCENES:
assert "id" in s
assert "label" in s
assert "desc" in s
assert isinstance(s["label"], str) and s["label"].strip()
@@ -0,0 +1,82 @@
"""Phase 0 回归用例 R1/R2/R3 — 固化三个反复出现的问题。
R1: 技能列表不得被硬性截断为 15 条(微信 Bot 曾只返回 15 个技能)。
R2: 显式选中技能/MCP 时,system prompt 必须注入「最高优先级覆盖规则」。
R3: 媒体生成(图像/视频)必须有超时保护,超时后回写用户可懂的提示而非永久挂起。
说明:在 chat.py 巨石(10070 行)尚未拆分前,R1/R2 采用「源码契约」守卫,
这是最低风险且能切实挡住回归的方式;R3 为行为级单元测试(mock 内部生成调用模拟超时)。
Phase 1 抽出 chat_media / chat_mcp / chat_skill 干净 seam 后,R1/R2 应升级为行为测试。
"""
import asyncio
import os
from unittest.mock import MagicMock
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
CHAT_PY = os.path.abspath(os.path.join(_HERE, "..", "app", "api", "chat.py"))
def _read_chat_source():
with open(CHAT_PY, "r", encoding="utf-8") as f:
return f.read()
# ---------- R1 ----------
def test_r1_skill_list_not_hard_capped_at_15():
"""微信 Bot 技能列表曾因 limit(15) 只返回 15 个,必须防止回归。"""
src = _read_chat_source()
assert "limit(15)" not in src, "检测到 limit(15),技能列表可能被硬性截断"
assert "LIMIT 15" not in src, "检测到 LIMIT 15,技能列表可能被硬性截断"
# ---------- R2 ----------
def test_r2_explicit_selection_override_rule_present():
"""显式选中技能/MCP 时,system prompt 必须包含最高优先级覆盖规则。"""
src = _read_chat_source()
assert "最高优先级覆盖规则" in src, "显式选中工具的覆盖规则文本缺失"
assert "只基于上文提供的该工具具体资料作答" in src, "覆盖规则应要求只基于已选工具资料作答"
assert "平台知识库" in src and "不要引用" in src, "覆盖规则应明确禁止引用平台知识库通用描述"
# ---------- R3 ----------
@pytest.mark.asyncio
async def test_r3_media_generation_timeout_returns_user_message(monkeypatch):
"""媒体生成超时必须回写用户可懂的超时提示,而非让前端永远『生成中…』。
注:_generate_media_task 是后台 fire-and-forget 任务(通过 asyncio.create_task 调用),
本身返回 None,超时保护体现在把提示回写到消息 content。故此处断言副作用。
"""
from app.api import chat as chat_mod
class FakeModel:
id = "m_test"
capabilities = ["image"] # 图像 -> 90s 超时
msg_mock = MagicMock() # 捕获被回写的消息对象
fake_session = MagicMock()
def _query_side_effect(model_cls):
q = MagicMock()
if model_cls is chat_mod.AIModel:
q.filter.return_value.first.return_value = FakeModel()
elif model_cls is chat_mod.Message:
q.filter.return_value.first.return_value = msg_mock
return q
fake_session.query.side_effect = _query_side_effect
monkeypatch.setattr(chat_mod, "SessionLocal", lambda: fake_session)
# 内部生成调用直接抛出 TimeoutError,等价模拟「生成耗时超过超时阈值」
async def _hang(db, ai_model, prompt, user_id):
raise asyncio.TimeoutError()
monkeypatch.setattr(chat_mod, "_generate_media", _hang)
await chat_mod._generate_media_task("msg1", "m_test", "画一只猫", "u1")
# 副作用:消息 content 被回写为用户可懂的超时提示
assert msg_mock.content is not None, "超时后必须回写消息内容,不能让前端永远『生成中…』"
assert "超时" in str(msg_mock.content), "媒体生成超时应回写用户可懂的超时提示"
+21
View File
@@ -0,0 +1,21 @@
"""Phase 0 安全网 — 后端冒烟测试。
核心价值:证明整个后端(含 chat.py 等巨型模块)在测试环境下能无错误导入并挂载路由。
任何破坏 import 的脆弱补丁都会在此立刻变红,从而把"改了没生效 / 启动即崩"挡在部署前。
"""
def test_app_imports_and_has_routes():
from main import app
assert app is not None
# 真实路由数远超 0;若 chat.py 等模块 import 失败,本测试在 import 阶段即报错
assert len(app.routes) > 50
def test_openapi_schema_builds():
from main import app
schema = app.openapi()
assert "paths" in schema
assert len(schema["paths"]) > 0
+21
View File
@@ -0,0 +1,21 @@
"""短信宝脚手架回归(稳定模块,纯函数)。
验证登录密码 MD5 算法与短信宝约定一致(32 位小写十六进制)。
这是短信验证码功能(注册/改密/充值)能连通短信宝的前提。
"""
import hashlib
from app.sms_client import smsbao_md5
def test_smsbao_md5_matches_hashlib():
samples = ["XHR521yhf", "abc123", "密码测试", ""]
for pw in samples:
assert smsbao_md5(pw) == hashlib.md5(pw.encode("utf-8")).hexdigest()
def test_smsbao_md5_is_32_lower_hex():
out = smsbao_md5("XHR521yhf")
assert len(out) == 32
assert out == out.lower()
assert all(c in "0123456789abcdef" for c in out)
+35 -33
View File
@@ -1,33 +1,35 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
}, "test": "vitest run"
"keywords": [], },
"author": "", "keywords": [],
"license": "ISC", "author": "",
"dependencies": { "license": "ISC",
"@types/qrcode": "^1.5.6", "dependencies": {
"highlight.js": "^11.11.1", "@types/qrcode": "^1.5.6",
"html2canvas": "^1.4.1", "highlight.js": "^11.11.1",
"jspdf": "^4.2.1", "html2canvas": "^1.4.1",
"katex": "^0.17.0", "jspdf": "^4.2.1",
"mermaid": "^11.16.0", "katex": "^0.17.0",
"qrcode": "^1.5.4", "mermaid": "^11.16.0",
"react": "^18.3.1", "qrcode": "^1.5.4",
"react-dom": "^18.3.1", "react": "^18.3.1",
"react-router-dom": "^6.30.3" "react-dom": "^18.3.1",
}, "react-router-dom": "^6.30.3"
"devDependencies": { },
"@types/react": "^18.3.29", "devDependencies": {
"@types/react-dom": "^18.3.7", "@types/react": "^18.3.29",
"@vitejs/plugin-react": "^4.7.0", "@types/react-dom": "^18.3.7",
"typescript": "^5.9.3", "@vitejs/plugin-react": "^4.7.0",
"vite": "^5.4.21" "typescript": "^5.9.3",
} "vite": "^5.4.21",
} "vitest": "^4.1.10"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { formatBjTime } from './datetime';
// 北京时间渲染曾是反复出现的问题(微信Bot时间错乱、Token驾驶舱时间列不正确等)。
// 该测试固化「后端按北京时间存储、前端显式按 Asia/Shanghai 渲染」的契约。
describe('formatBjTime', () => {
it('空输入返回占位符 -', () => {
expect(formatBjTime(undefined)).toBe('-');
expect(formatBjTime(null)).toBe('-');
expect(formatBjTime('')).toBe('-');
});
it('无时区标记的时间戳按 Asia/Shanghai (+08:00) 渲染', () => {
// 2026-07-18T12:00:00 视为北京时间,渲染应含 12:00:00
const out = formatBjTime('2026-07-18T12:00:00');
expect(out).toContain('2026');
expect(out).toContain('12:00:00');
});
it('UTC(Z) 时间戳正确转换为北京时间 (+8)', () => {
// 2026-07-18T04:00:00Z = 北京时间 12:00:00
const out = formatBjTime('2026-07-18T04:00:00Z');
expect(out).toContain('12:00:00');
});
it('非法日期原样返回', () => {
expect(formatBjTime('not-a-date')).toBe('not-a-date');
});
});
+7 -3
View File
@@ -1,4 +1,5 @@
import { defineConfig } from 'vite'; /// <reference types="vitest/config" />
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
@@ -17,8 +18,6 @@ export default defineConfig({
chunkSizeWarningLimit: 1200, chunkSizeWarningLimit: 1200,
rollupOptions: { rollupOptions: {
output: { output: {
// 仅将 react 核心库单独打包(长期缓存);
// 其余第三方库交给 Rollup 自动按需拆分,避免被强制合并成一个大 vendor 在首屏加载
manualChunks(id) { manualChunks(id) {
if (id.includes('node_modules')) { if (id.includes('node_modules')) {
if ( if (
@@ -37,4 +36,9 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
}, },
// Phase 0 测试基建:vitest 配置(纯函数测试用 node 环境,组件测试可改 jsdom)
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
}); });
+115
View File
@@ -0,0 +1,115 @@
"""Minimal CI gate for qihuaiflow.
This is the canonical, single-source-of-truth gate. It runs the two test
suites that guard production behavior, INSIDE the long-lived qihu-aiflow
container (our deployment model keeps the app running in a container, not
built fresh in CI):
1. backend pytest -> /app/backend/python/.venv/bin/python -m pytest -c pytest.ini
2. frontend vitest -> /app/frontend && npx vitest run
Exit codes:
0 all suites green -> deploy/push may proceed
1 a test suite failed -> BLOCK (do not deploy/push)
2 environment error -> docker missing or container not running
It auto-detects where it runs:
* If the qihu-aiflow container is reachable via LOCAL docker (i.e. on the
deploy host), it execs directly.
* Otherwise it falls back to ssh_helper (remote control from a laptop),
which reaches the host over SSH and execs into the container there.
Usage:
python scripts/ci_gate.py
"""
import os
import subprocess
import sys
CONTAINER = "qihu-aiflow"
BACKEND_CMD = "cd /app/backend/python && .venv/bin/python -m pytest -c pytest.ini -q"
FRONTEND_CMD = "cd /app/frontend && npx vitest run --reporter=dot"
def shell_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def local_runner(cmd: str):
"""Run via local docker (deploy host)."""
p = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return p.stdout, p.stderr, p.returncode
def get_runner():
# Prefer local docker ONLY if the actual container is reachable here
# (avoids a Windows-docker edge case where `docker ps` works but the
# qihu-aiflow container lives on a different host).
try:
r = subprocess.run(
"docker ps --format '{{.Names}}'",
shell=True, capture_output=True, text=True, timeout=10,
)
if r.returncode == 0 and CONTAINER in r.stdout.split():
return local_runner
except Exception:
pass
# Fall back to SSH control of the real host.
try:
import ssh_helper as s # type: ignore
return lambda cmd: s.run(cmd)
except Exception as e: # pragma: no cover
print(f"[CI Gate] ERROR: docker unavailable locally and ssh_helper missing: {e}",
file=sys.stderr)
sys.exit(2)
def run_in_container(runner, cmd: str):
return runner(f"docker exec {CONTAINER} bash -c {shell_quote(cmd)}")
def container_up(runner) -> bool:
out, _, _ = runner(f"docker ps --format {shell_quote('{{.Names}}')}")
return CONTAINER in out.split()
def main():
runner = get_runner()
if not container_up(runner):
print(f"[CI Gate] ERROR: container {CONTAINER} is not running", file=sys.stderr)
sys.exit(2)
failures = []
print("=== [CI Gate] backend pytest ===")
o, e, rc = run_in_container(runner, BACKEND_CMD)
sys.stdout.write(o)
if e.strip():
print("STDERR:", e.strip(), file=sys.stderr)
if rc != 0:
failures.append("backend pytest")
print(f"[CI Gate] backend pytest FAILED (rc={rc})", file=sys.stderr)
else:
print("[CI Gate] backend pytest OK")
print("=== [CI Gate] frontend vitest ===")
o, e, rc = run_in_container(runner, FRONTEND_CMD)
sys.stdout.write(o)
if e.strip():
print("STDERR:", e.strip(), file=sys.stderr)
if rc != 0:
failures.append("frontend vitest")
print(f"[CI Gate] frontend vitest FAILED (rc={rc})", file=sys.stderr)
else:
print("[CI Gate] frontend vitest OK")
if failures:
print(f"\n[CI Gate] BLOCKED - failed: {', '.join(failures)}", file=sys.stderr)
sys.exit(1)
print("\n[CI Gate] PASSED - all suites green")
sys.exit(0)
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# qihuaiflow minimal CI gate - git pre-push hook.
#
# Runs backend pytest + frontend vitest INSIDE the long-lived qihu-aiflow
# container before any `git push` leaves this host. If a suite fails the
# push is blocked (exit non-zero), so a broken tree can never be published.
#
# Install (on the deploy host, inside the repo):
# cp scripts/git-hooks/pre-push .git/hooks/pre-push
# chmod +x .git/hooks/pre-push
#
# This is a self-contained bash gate (no python dependency on the host).
# The canonical, documented version lives in scripts/ci_gate.py.
set -uo pipefail
CONTAINER="qihu-aiflow"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "[CI Gate] ERROR: container $CONTAINER is not running - aborting push" >&2
exit 2
fi
echo "=== [CI Gate] backend pytest ==="
docker exec "$CONTAINER" bash -c 'cd /app/backend/python && .venv/bin/python -m pytest -c pytest.ini -q'
backend_rc=$?
if [ "$backend_rc" -ne 0 ]; then
echo "[CI Gate] backend pytest FAILED (rc=$backend_rc) - push blocked" >&2
exit 1
fi
echo "[CI Gate] backend pytest OK"
echo "=== [CI Gate] frontend vitest ==="
docker exec "$CONTAINER" bash -c 'cd /app/frontend && npx vitest run --reporter=dot'
frontend_rc=$?
if [ "$frontend_rc" -ne 0 ]; then
echo "[CI Gate] frontend vitest FAILED (rc=$frontend_rc) - push blocked" >&2
exit 1
fi
echo "[CI Gate] frontend vitest OK"
echo "[CI Gate] PASSED - all suites green - push allowed"
exit 0