diff --git a/.gitignore b/.gitignore index 2824b07..2b22a7f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ dist/ node_modules/ .DS_Store *.log +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +backend/data/ diff --git a/README.md b/README.md index 2c665f7..9578e82 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,85 @@ # 蝶雅产品AI中心 -面向产品部的对外统一工作台,当前只展示两个正式产品工具:风险预警、成本识别。项目采用原生 HTML、CSS、JavaScript 模块,无第三方依赖,可直接部署到宝塔 Nginx 静态网站。 +面向产品部的对外统一工作台,当前只展示两个正式产品工具:风险预警、成本识别。项目按 frontend/ + backend/ 分层,采用 Vite 前端、FastAPI 后端与 SQLite 持久化统计。 -## 已集成入口 +## 目录 + +- frontend/:工作台卡片、浅色/深色主题、使用统计页面、前端测试及构建配置。 +- backend/:FastAPI API、SQLite 数据库、统计接口和后端测试。 +- deploy/nginx.conf.example:Nginx 静态站点与 API 反向代理配置示例。 + +## 使用统计 + +点击“打开工作台”时,前端向 POST /api/apps/{app_id}/click 发送一次请求,后端在 SQLite 中为该入口增加一次点击。使用统计页面通过 GET /api/stats 读取总次数、分应用次数和最近更新时间。后端只接受 risk、cost 两个白名单入口。 + +数据默认保存在 backend/data/usage.sqlite3。数据库使用事务与 WAL,服务重启后记录会保留。运行数据、密钥和依赖环境不会提交 Git。 + +## 本地运行 + +后端需要 Python 3.10+: + +```powershell +cd backend +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -r requirements.txt +uvicorn app.main:app --reload --host 127.0.0.1 --port 8800 +``` + +另开终端启动前端: + +```powershell +cd frontend +npm install +npm run dev +``` + +访问 http://127.0.0.1:5173。Vite 会把 /api 代理到 127.0.0.1:8800。 + +前端检查和构建: + +```powershell +cd frontend +npm test +npm run build +``` + +后端测试: + +```powershell +cd backend +python -m unittest discover -s tests -p "test_*.py" -v +``` + +## 宝塔部署 + +先在 /www/wwwroot/dieya-app-portal/backend 安装后端依赖: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +.venv/bin/gunicorn -w 2 -k uvicorn.workers.UvicornWorker app.main:app -b 127.0.0.1:8800 +``` + +宝塔使用 Python 项目或 Supervisor 托管该命令。工作目录是 backend,运行用户建议为 www,并确保 backend/data 可写。 + +再构建前端: + +```bash +cd /www/wwwroot/dieya-app-portal/frontend +npm install +npm run build +``` + +网站根目录设为 /www/wwwroot/dieya-app-portal/frontend/dist。Nginx 把 /api/ 转发到 127.0.0.1:8800,其余路径返回 index.html,配置示例见 deploy/nginx.conf.example。前后端使用同一门户域名时无需额外配置 CORS。 + +部署后可用 curl https://你的域名/api/health 检查后端,用 curl https://你的域名/api/stats 查看统计。打开工作台后刷新“使用统计”,对应次数应增加 1。 + +## 应用范围 | 应用 | 地址 | 环境 | | --- | --- | --- | | 风险预警 | http://risk-prod.dieya.net.cn/ | 正式 | | 成本识别 | http://cost.dieya.net.cn/ | 正式 | -其他业务入口、测试版应用和制度小助手不在本门户展示。 - -## 本地启动 - -需要 Node.js 18 或更高版本。项目没有第三方依赖,不需要 `npm install`: - -```sh -npm test -npm run dev -``` - -默认访问 http://127.0.0.1:5173,也可以指定端口:`npm run dev -- --port 8080`。 - -## 宝塔部署 - -生产环境只部署 `dist/` 目录中的静态文件: - -```sh -npm run build -``` - -将 `dist/` 里面的文件上传到网站目录,或在宝塔网站设置中将网站根目录指向: - -```text -/www/wwwroot/dieya-product-ai-center/dist -``` - -不需要创建 Node 项目,不需要 PHP,不需要反向代理。网站默认文档使用 `index.html`。服务器需为 `.mjs` 文件返回 JavaScript 类型,Nginx 可在站点配置中加入: - -```nginx -location ~ \.mjs$ { - types { application/javascript mjs; } - add_header Cache-Control "no-cache"; - try_files $uri =404; -} -``` - -## 修改入口 - -编辑 `src/apps.mjs` 中的 `applications` 数组,然后重新构建并部署 `dist`。当前配置会校验应用必须是产品部正式入口,不允许测试环境配置混入对外门户。 - -## 页面功能 - -- 参考多智能体工作台的简洁卡片风格,支持浅色和深色主题按钮切换。 -- 顶部品牌导航、产品部工具卡片和响应式布局,避免复杂的筛选与侧栏层级。 -- 每张卡片展示产品部标签、正式应用状态和打开工作台按钮。 -- 主题选择保存在当前浏览器中,不影响应用权限。 - -目标业务系统仍需自行负责登录、钉钉免登、可信域名和权限校验。本门户只负责统一展示和跳转。 +其他业务入口、测试版应用和制度小助手不在门户展示。目标业务系统仍负责登录、钉钉免登及权限校验。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..dde64cd --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +PORTAL_DATABASE=./data/usage.sqlite3 +PORTAL_CORS_ORIGINS= +HOST=127.0.0.1 +PORT=8800 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..925ab13 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""蝶雅产品AI中心统计 API。""" diff --git a/backend/app/catalog.py b/backend/app/catalog.py new file mode 100644 index 0000000..9952659 --- /dev/null +++ b/backend/app/catalog.py @@ -0,0 +1,16 @@ +"""Server-side allowlist for public product workbench links.""" + +APPLICATIONS = { + "risk": { + "id": "risk", + "name": "风险预警", + "url": "http://risk-prod.dieya.net.cn/", + "category": "product", + }, + "cost": { + "id": "cost", + "name": "成本识别", + "url": "http://cost.dieya.net.cn/", + "category": "product", + }, +} diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..2659fbf --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,56 @@ +"""SQLite persistence for workbench click counts.""" + +import os +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + + +def database_path() -> Path: + configured = Path(os.getenv("PORTAL_DATABASE", "./data/usage.sqlite3")).expanduser() + return configured if configured.is_absolute() else BACKEND_ROOT / configured + + +def connect(path: Path | None = None) -> sqlite3.Connection: + resolved = path or database_path() + resolved.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(resolved, timeout=15) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout=15000") + connection.execute("PRAGMA foreign_keys=ON") + return connection + + +def initialize(path: Path | None = None) -> None: + with connect(path) as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS application_usage ( + app_id TEXT PRIMARY KEY, + click_count INTEGER NOT NULL DEFAULT 0 CHECK (click_count >= 0), + updated_at TEXT + ) + """ + ) + connection.commit() + + +@contextmanager +def session(path: Path | None = None): + connection = connect(path) + try: + yield connection + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..b8faf82 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,85 @@ +"""FastAPI entry point for persistent product workbench usage statistics.""" + +import os +from contextlib import asynccontextmanager +from urllib.parse import urlsplit + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware + +from .catalog import APPLICATIONS +from .database import initialize, session, utc_now + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + initialize() + yield + + +app = FastAPI(title="蝶雅产品AI中心统计 API", version="1.0.0", lifespan=lifespan) +cors_origins = [origin.strip() for origin in os.getenv("PORTAL_CORS_ORIGINS", "").split(",") if origin.strip()] +if cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_methods=["GET", "POST"], + allow_headers=["Content-Type"], + ) + + +@app.get("/api/health") +def health(): + with session() as connection: + connection.execute("SELECT 1").fetchone() + return {"status": "ok"} + + +@app.get("/api/stats") +def get_stats(): + with session() as connection: + rows = connection.execute( + "SELECT app_id, click_count, updated_at FROM application_usage ORDER BY app_id" + ).fetchall() + counts = {row["app_id"]: row for row in rows} + items = [ + { + **application, + "click_count": counts[app_id]["click_count"] if app_id in counts else 0, + "updated_at": counts[app_id]["updated_at"] if app_id in counts else None, + } + for app_id, application in APPLICATIONS.items() + ] + timestamps = [item["updated_at"] for item in items if item["updated_at"]] + return { + "items": items, + "total_clicks": sum(item["click_count"] for item in items), + "updated_at": max(timestamps) if timestamps else None, + } + + +@app.post("/api/apps/{app_id}/click", status_code=201) +def record_click(app_id: str, request: Request): + application = APPLICATIONS.get(app_id) + if application is None: + raise HTTPException(status_code=404, detail="工作台不存在") + + origin = request.headers.get("origin") + request_host = request.headers.get("x-forwarded-host") or request.headers.get("host", "") + origin_host = urlsplit(origin).netloc if origin else "" + if origin and origin_host.lower() != request_host.lower(): + allowed = {item.strip().rstrip("/") for item in os.getenv("PORTAL_CORS_ORIGINS", "").split(",") if item.strip()} + if origin.rstrip("/") not in allowed: + raise HTTPException(status_code=403, detail="来源不受允许") + + now = utc_now() + with session() as connection: + connection.execute( + """INSERT INTO application_usage (app_id, click_count, updated_at) VALUES (?, 1, ?) + ON CONFLICT(app_id) DO UPDATE SET click_count = click_count + 1, updated_at = excluded.updated_at""", + (app_id, now), + ) + row = connection.execute( + "SELECT click_count FROM application_usage WHERE app_id = ?", (app_id,) + ).fetchone() + return {"app_id": app_id, "click_count": row["click_count"], "updated_at": now} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4a481cc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.34,<1.0 +httpx>=0.28,<1.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..8fbcc2e --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,56 @@ +"""API tests using isolated SQLite files.""" + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.database import initialize, session +from app.main import app + + +class UsageApiTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.db_path = Path(self.temp.name) / "usage.sqlite3" + initialize(self.db_path) + self.initialize_patch = patch("app.main.initialize", side_effect=lambda: initialize(self.db_path)) + self.initialize_patch.start() + self.database_patch = patch("app.main.session", side_effect=lambda: session(self.db_path)) + self.database_patch.start() + self.client_context = TestClient(app) + self.client = self.client_context.__enter__() + + def tearDown(self): + self.client_context.__exit__(None, None, None) + self.database_patch.stop() + self.initialize_patch.stop() + self.temp.cleanup() + + def test_health_and_empty_stats(self): + self.assertEqual(self.client.get("/api/health").json(), {"status": "ok"}) + result = self.client.get("/api/stats").json() + self.assertEqual(result["total_clicks"], 0) + self.assertEqual({item["id"]: item["click_count"] for item in result["items"]}, {"risk": 0, "cost": 0}) + + def test_each_click_increments_once_and_persists(self): + self.assertEqual(self.client.post("/api/apps/risk/click", json={}).status_code, 201) + self.assertEqual(self.client.post("/api/apps/risk/click", json={}).status_code, 201) + self.assertEqual(self.client.post("/api/apps/cost/click", json={}).status_code, 201) + stats = self.client.get("/api/stats").json() + self.assertEqual(stats["total_clicks"], 3) + self.assertEqual({item["id"]: item["click_count"] for item in stats["items"]}, {"risk": 2, "cost": 1}) + self.assertIsNotNone(stats["updated_at"]) + + def test_only_allowlisted_apps_can_be_counted(self): + self.assertEqual(self.client.post("/api/apps/unknown/click", json={}).status_code, 404) + + def test_rejects_cross_origin_click(self): + response = self.client.post("/api/apps/risk/click", json={}, headers={"Origin": "https://untrusted.example"}) + self.assertEqual(response.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index 10a2cca..0b10bf0 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -1,21 +1,19 @@ -# 把 dist/ 的内容上传到此目录,再按实际域名、路径与 HTTPS 证书修改配置。 -server { - listen 80; - server_name portal.example.com; - root /var/www/dieya-product-ai-center; - index index.html; +# 在宝塔现有站点 server {} 中合并 location;保留证书和域名配置。 +root /www/wwwroot/dieya-app-portal/frontend/dist; +index index.html; - location / { - try_files $uri $uri/ =404; - } - - # .mjs 必须使用 JavaScript MIME 类型。 - location ~ \.mjs$ { - types { application/javascript mjs; } - add_header Cache-Control "no-cache"; - } - - location = /index.html { - add_header Cache-Control "no-cache"; - } +location ^~ /api/ { + proxy_pass http://127.0.0.1:8800; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; +} + +location / { + try_files $uri $uri/ /index.html; } diff --git a/favicon.svg b/frontend/favicon.svg similarity index 100% rename from favicon.svg rename to frontend/favicon.svg diff --git a/index.html b/frontend/index.html similarity index 50% rename from index.html rename to frontend/index.html index b0ac2eb..356ee7b 100644 --- a/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@