diff --git a/.gitignore b/.gitignore index d03a2b4..92c5145 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Python / backend .venv/ +.uv-cache/ __pycache__/ *.pyc .pytest_cache/ @@ -10,6 +11,7 @@ backend/data/ # Node / frontend node_modules/ +.npm-cache/ dist/ .vite/ *.tsbuildinfo diff --git a/README.md b/README.md index 55fd9ce..4b891c6 100644 --- a/README.md +++ b/README.md @@ -70,29 +70,44 @@ ## 启动 +新贡献者可先阅读 [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md),里面包含本地验证命令和常见环境问题。 + ### 后端(终端 1) ```bash cd backend -uv sync # 创建 .venv 并安装依赖 cp .env.example .env # 按需填写 LLM / 高德 凭证(可留空) +uv sync # 创建 .venv 并安装依赖 uv run uvicorn app.main:app --reload --port 8000 ``` 启动钩子会自动建表(无需手动 `init_db`;无默认账号,访客可直接规划)。`uv run python -m app.seed` 可选,塞一条「成都三天」示例行程。 -测试:在 `backend` 目录执行 `uv run pytest`(CRUD / Workflow 契约(含截断修复、POI 列表估天数)/ 候选 / 选城 / 交通 / 鉴权 / schema 校验,**46 项**)。 +测试:在 `backend` 目录执行 `uv run pytest`(CRUD / Workflow 契约(含截断修复、POI 列表估天数)/ 候选 / 选城 / 高德 POI 服务 / 交通 / 鉴权 / schema 校验 / DB 初始化,**60 项**)。 + +受限沙箱或 CI 环境如果不能写用户级缓存,可用项目内缓存: + +```bash +UV_CACHE_DIR=.uv-cache uv sync +UV_CACHE_DIR=.uv-cache uv run pytest +``` ### 前端(终端 2) ```bash cd frontend -npm install cp .env.example .env # VITE_API_BASE 默认 http://localhost:8000 +npm install npm run dev # http://localhost:5173(被占用自动切 5174) ``` 测试:`npm test`(Vitest,10 项);类型检查 + 构建:`npm run build`。 +受限沙箱或 CI 环境如果不能写用户级 npm cache,可用项目内缓存: + +```bash +npm install --cache .npm-cache +``` + ## 账号与登录 - 无登录页、无默认账号:访客可完整体验规划流程(草案存于前端,不落库)。 @@ -111,10 +126,11 @@ npm run dev # http://localhost:5173(被占用自动 ## 地图配置 在 `frontend/.env` 填入高德 JS API 2.0 凭证后,工作台右侧地图实时打点连线;留空则地图区降级为坐标列表(不白屏)。 +在 `backend/.env` 填入可选的 `AMAP_KEY` 后,后端 POI 候选和交通兜底会优先使用高德 Web 服务;不填则继续使用本地桩数据,不影响本地跑通。 - 控制台 https://console.amap.com/dev/key/app → 应用管理 → 创建应用 → 添加 Key → 服务平台选「Web端(JS API)」。 - 拿到 `VITE_AMAP_JS_KEY` 与安全密钥 `VITE_AMAP_SECURITY_CODE`(jscode,JS API 2.0 必填)。 -- 交通时长 / 路径由前端高德插件(`AMap.Driving/Walking/Transfer`)按需计算;点聚合用 `AMap.MarkerCluster`;后端 `AMAP_KEY` 为 `POST /transit/recompute` 兜底用。 +- 交通时长 / 路径由前端高德插件(`AMap.Driving/Walking/Transfer`)按需计算;点聚合用 `AMap.MarkerCluster`;后端 `AMAP_KEY` 为 `POST /transit/recompute` 与 POI 候选兜底用。 ## API 一览 @@ -131,6 +147,7 @@ npm run dev # http://localhost:5173(被占用自动 ## 自检 - `curl http://localhost:8000/health` → `{"status":"ok"}`。 +- 若当前环境不能绑定本地端口,可在 `backend` 目录执行 `uv run python -c 'from fastapi.testclient import TestClient; from app.main import app; r = TestClient(app).get("/health"); print(r.status_code, r.json())'` 做进程内健康检查。 - 注册并登录:`curl -X POST .../auth/register -H 'Content-Type: application/json' -d '{"email":"you@example.com","password":"itravel123"}'` 得 token。 - 流式规划:`curl -N -X POST .../plan/stream -H 'Content-Type: application/json' -d '{"destination":"成都","origin":"北京","day_count":2,"free_text":"想轻松逛吃"}'` → 一串 `event: status/intent/skeleton/reply/day/itinerary/done`。 - 景点候选:`curl -X POST .../plan/candidates -H 'Content-Type: application/json' -d '{"city":"杭州","category":"play","limit":4}'` → `{pois, degraded}`。 diff --git a/backend/.env.example b/backend/.env.example index 09ec2cf..d3ab2be 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,7 +24,7 @@ OPENAI_REASONING_EFFORT= # 本应用只需结构化 JSON,建议 disabled —— 必须显式关闭,仅省略不生效。 OPENAI_THINKING=disabled -# 高德地图(服务端 Key,M0 不调用,预留) +# 高德地图 Web 服务 Key(可选)。不填也能本地跑通;POI / 交通会走本地兜底。 AMAP_KEY= # 鉴权(M5 启用;本地随便填一个长随机串即可) diff --git a/backend/app/config.py b/backend/app/config.py index 74bfe67..50c7091 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -28,7 +28,7 @@ class Settings(BaseSettings): # 兼容 true/false:true→enabled,false→disabled。 openai_thinking: str = "" - # 高德(M0 预留) + # 高德 Web 服务 Key(可选):未配置时 POI / 交通服务自动走本地兜底。 amap_key: str = "" jwt_secret: str = "dev-only-change-me" diff --git a/backend/app/db.py b/backend/app/db.py index 49dbae3..ae0a157 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from pathlib import Path from sqlmodel import Session, SQLModel, create_engine @@ -14,10 +15,22 @@ engine = create_engine(settings.database_url, echo=False, connect_args=connect_args) +def _ensure_sqlite_parent_dir() -> None: + if not engine.url.drivername.startswith("sqlite"): + return + database = engine.url.database + if not database or database == ":memory:": + return + path = Path(database) + if path.parent != Path("."): + path.parent.mkdir(parents=True, exist_ok=True) + + def init_db() -> None: # 导入 models 以注册到 SQLModel.metadata import app.models # noqa: F401 + _ensure_sqlite_parent_dir() SQLModel.metadata.create_all(engine) diff --git a/backend/app/services/amap_service.py b/backend/app/services/amap_service.py new file mode 100644 index 0000000..5a6811b --- /dev/null +++ b/backend/app/services/amap_service.py @@ -0,0 +1,132 @@ +from dataclasses import dataclass + +import httpx +from cachetools import TTLCache + +from app import amap_stub, validators +from app.config import settings +from app.schemas import POICreate + +_SEARCH_URL = "https://restapi.amap.com/v3/place/text" +_CACHE: TTLCache = TTLCache(maxsize=256, ttl=3600) + +_CATEGORY_KEYWORD = { + "eat": "美食", + "stay": "酒店", + "play": "景点", +} +_CATEGORY_TYPES = { + "eat": "050000", + "stay": "100000", + "play": "110000", +} + + +@dataclass +class CandidateResult: + pois: list[POICreate] + degraded: bool + + +def clear_cache() -> None: + _CACHE.clear() + + +def _cache_key(city: str, category: str, keyword: str, limit: int) -> str: + return f"{city.strip()}|{category}|{keyword.strip()}|{limit}" + + +def _text(value) -> str | None: + if isinstance(value, str): + value = value.strip() + return value or None + return None + + +def _parse_location(value) -> tuple[float, float] | None: + if not isinstance(value, str) or "," not in value: + return None + lng_s, lat_s = value.split(",", 1) + try: + lng, lat = float(lng_s), float(lat_s) + except ValueError: + return None + if not validators.valid_coord(lng, lat): + return None + return lng, lat + + +def _poi_from_amap(raw: dict, category: str) -> POICreate | None: + name = _text(raw.get("name")) + loc = _parse_location(raw.get("location")) + if not name or loc is None: + return None + lng, lat = loc + return POICreate( + amap_id=_text(raw.get("id")), + name=name, + category=category, + lng=lng, + lat=lat, + address=_text(raw.get("address")), + ) + + +def _query_amap(city: str, category: str, keyword: str, limit: int) -> list[POICreate]: + if not settings.amap_key: + return [] + query = keyword.strip() or _CATEGORY_KEYWORD.get(category, "景点") + offset = max(1, min(max(limit, 10), 25)) + params = { + "key": settings.amap_key, + "keywords": query, + "types": _CATEGORY_TYPES.get(category, ""), + "city": city, + "citylimit": "true", + "offset": str(offset), + "page": "1", + "extensions": "base", + "output": "JSON", + } + response = httpx.get(_SEARCH_URL, params=params, timeout=4.0) + data = response.json() + if data.get("status") != "1": + return [] + pois = data.get("pois") + if not isinstance(pois, list): + return [] + parsed = [_poi_from_amap(p, category) for p in pois if isinstance(p, dict)] + return [p for p in parsed if p is not None] + + +def candidates( + city: str, + category: str, + *, + keyword: str = "", + exclude: set[str] | None = None, + limit: int = 4, +) -> CandidateResult: + exclude = exclude or set() + if not settings.amap_key: + fallback = amap_stub.candidates(city, category, exclude=exclude, limit=limit) + return CandidateResult(pois=fallback, degraded=True) + + query_limit = max(limit * 2, limit) + key = _cache_key(city, category, keyword, query_limit) + + pois = _CACHE.get(key) + if pois is None: + try: + pois = _query_amap(city, category, keyword, query_limit) + except Exception: + pois = [] + if pois: + _CACHE[key] = pois + + filtered = [p for p in pois if p.name not in exclude] if pois else [] + if filtered: + return CandidateResult(pois=filtered[:limit], degraded=False) + + fallback = amap_stub.candidates(city, category, exclude=exclude, limit=limit) + return CandidateResult(pois=fallback, degraded=True) diff --git a/backend/app/services/plan_service.py b/backend/app/services/plan_service.py index 935c236..85ed8af 100644 --- a/backend/app/services/plan_service.py +++ b/backend/app/services/plan_service.py @@ -2,7 +2,8 @@ import logging from collections.abc import Callable, Iterator -from app import amap_stub, llm, validators, workflow +from app import llm, validators, workflow +from app.services import amap_service from app.schemas import ItineraryCreate logger = logging.getLogger(__name__) @@ -258,6 +259,7 @@ def stream_plan_events(req) -> Iterator[str]: def _poi_out(p) -> dict: return { + "amap_id": p.amap_id, "name": p.name, "category": p.category, "lng": p.lng, @@ -299,8 +301,13 @@ def candidate_pois( return {"pois": [_poi_out(p) for p in pois[:limit]], "degraded": False} except Exception: logger.info("candidates_degraded city=%s category=%s", city, category) - stub = amap_stub.candidates(city, category or "play", limit=limit) - return {"pois": [_poi_out(p) for p in stub], "degraded": True} + result = amap_service.candidates( + city, + category or "play", + keyword=keyword, + limit=limit, + ) + return {"pois": [_poi_out(p) for p in result.pois], "degraded": result.degraded} # ---- 候选城市(route_first path B 选城,#11)---- diff --git a/backend/app/services/poi_service.py b/backend/app/services/poi_service.py index 60cc442..401b920 100644 --- a/backend/app/services/poi_service.py +++ b/backend/app/services/poi_service.py @@ -1,6 +1,7 @@ import logging -from app import amap_stub, llm, validators, workflow +from app import llm, validators, workflow +from app.services import amap_service logger = logging.getLogger(__name__) @@ -30,6 +31,7 @@ def build_category_prompt(city: str, category: str, exclude: list[str]) -> list[ def poi_dicts(pois) -> list[dict]: return [ { + "amap_id": p.amap_id, "name": p.name, "category": p.category, "lng": p.lng, @@ -64,7 +66,8 @@ def regenerate_llm(city: str, category: str, exclude: list[str]) -> tuple[list, logger.exception("poi_regenerate_failed city=%s category=%s", city, category) break logger.info("poi_degraded city=%s category=%s", city, category) - return amap_stub.candidates(city, category, exclude=exclude_set), True + result = amap_service.candidates(city, category, exclude=exclude_set) + return result.pois, result.degraded def get_candidates( @@ -80,8 +83,7 @@ def get_candidates( if regenerate: pois, degraded = regenerate_llm(city, category, exclude_items) else: - pois, degraded = amap_stub.candidates( - city, category, exclude=set(exclude_items) - ), False + result = amap_service.candidates(city, category, exclude=set(exclude_items)) + pois, degraded = result.pois, result.degraded return {"pois": poi_dicts(pois), "degraded": degraded} diff --git a/backend/tests/test_amap_service.py b/backend/tests/test_amap_service.py new file mode 100644 index 0000000..5d7d174 --- /dev/null +++ b/backend/tests/test_amap_service.py @@ -0,0 +1,96 @@ +from app.services import amap_service + + +class _Resp: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +def test_candidates_without_key_uses_stub_without_network(monkeypatch): + amap_service.clear_cache() + monkeypatch.setattr(amap_service.settings, "amap_key", "") + + def _boom(*_args, **_kwargs): + raise AssertionError("AMap should not be called without AMAP_KEY") + + monkeypatch.setattr(amap_service.httpx, "get", _boom) + + result = amap_service.candidates(city="成都", category="play", limit=3) + + assert result.degraded is True + assert len(result.pois) >= 3 + assert all(p.category in {"eat", "stay", "play"} for p in result.pois) + + +def test_candidates_with_key_parses_response_and_caches(monkeypatch): + amap_service.clear_cache() + monkeypatch.setattr(amap_service.settings, "amap_key", "test-key") + calls = [] + + def _fake_get(url, params, timeout): + calls.append((url, params, timeout)) + return _Resp( + { + "status": "1", + "pois": [ + { + "id": "B001", + "name": "西湖风景名胜区", + "address": "杭州市西湖区", + "location": "120.143222,30.236064", + }, + { + "id": "B002", + "name": "坏坐标", + "address": "杭州市", + "location": "999,999", + }, + ], + } + ) + + monkeypatch.setattr(amap_service.httpx, "get", _fake_get) + + first = amap_service.candidates(city="杭州", category="play", limit=5) + second = amap_service.candidates(city="杭州", category="play", limit=5) + + assert first.degraded is False + assert [p.name for p in first.pois] == ["西湖风景名胜区"] + assert first.pois[0].amap_id == "B001" + assert first.pois[0].lng == 120.143222 + assert second.pois[0].name == "西湖风景名胜区" + assert len(calls) == 1 + assert calls[0][1]["citylimit"] == "true" + + +def test_candidates_filters_excluded_and_degrades_when_empty(monkeypatch): + amap_service.clear_cache() + monkeypatch.setattr(amap_service.settings, "amap_key", "test-key") + + def _fake_get(*_args, **_kwargs): + return _Resp( + { + "status": "1", + "pois": [ + { + "id": "B001", + "name": "武侯祠", + "address": "武侯区", + "location": "104.0476,30.6464", + } + ], + } + ) + + monkeypatch.setattr(amap_service.httpx, "get", _fake_get) + + result = amap_service.candidates( + city="成都", category="play", exclude={"武侯祠"}, limit=4 + ) + + assert result.degraded is True + assert "武侯祠" not in [p.name for p in result.pois] + assert len(result.pois) >= 1 diff --git a/backend/tests/test_db_init.py b/backend/tests/test_db_init.py new file mode 100644 index 0000000..5c30ffb --- /dev/null +++ b/backend/tests/test_db_init.py @@ -0,0 +1,18 @@ +from sqlmodel import create_engine + +from app import db + + +def test_init_db_creates_sqlite_parent_directory(tmp_path, monkeypatch): + database_path = tmp_path / "nested" / "app.db" + test_engine = create_engine( + f"sqlite:///{database_path}", + connect_args={"check_same_thread": False}, + ) + + monkeypatch.setattr(db, "engine", test_engine) + + db.init_db() + + assert database_path.parent.is_dir() + assert database_path.exists() diff --git a/backend/tests/test_m3_candidates.py b/backend/tests/test_m3_candidates.py index 5fd5a13..6ad722a 100644 --- a/backend/tests/test_m3_candidates.py +++ b/backend/tests/test_m3_candidates.py @@ -4,6 +4,9 @@ from app.main import app from app.routers import poi +from app.schemas import POICreate +from app.services import amap_service +from app.services import plan_service client = TestClient(app) @@ -14,17 +17,32 @@ def _gen(messages, max_tokens=2000): return _gen -def test_candidates_initial_uses_stub_no_llm(monkeypatch): - # 初始填充(regenerate=false)即时走桩,绝不调 LLM +def test_candidates_initial_uses_poi_service_no_llm(monkeypatch): + # 初始填充(regenerate=false)走 POI 数据服务,绝不调 LLM def _boom(*a, **k): raise AssertionError("初始填充不应调用 LLM") monkeypatch.setattr(poi.llm, "stream_chat", _boom) + monkeypatch.setattr( + poi.poi_service.amap_service, + "candidates", + lambda city, category, exclude, limit=4, **_kwargs: amap_service.CandidateResult( + pois=[ + POICreate( + name="西湖风景名胜区", + category="play", + lng=120.143222, + lat=30.236064, + ) + ], + degraded=False, + ), + ) r = client.get("/poi/candidates", params={"city": "成都", "category": "play"}) assert r.status_code == 200 body = r.json() assert body["degraded"] is False - assert len(body["pois"]) >= 3 + assert [p["name"] for p in body["pois"]] == ["西湖风景名胜区"] assert all("name" in p for p in body["pois"]) @@ -72,6 +90,35 @@ def test_candidates_regenerate_non_json_degrades(monkeypatch): assert len(body["pois"]) >= 1 +def test_plan_candidates_llm_failure_uses_amap_service(monkeypatch): + monkeypatch.setattr(plan_service.llm, "stream_chat", _mock_stream("抱歉无法推荐")) + monkeypatch.setattr( + plan_service.amap_service, + "candidates", + lambda city, category, keyword="", limit=8, **_kwargs: amap_service.CandidateResult( + pois=[ + POICreate( + name="灵隐寺", + category="play", + lng=120.1012, + lat=30.2400, + address="杭州市西湖区", + ) + ], + degraded=False, + ), + ) + + r = client.post( + "/plan/candidates", + json={"city": "杭州", "category": "play", "limit": 4}, + ) + + body = r.json() + assert body["degraded"] is False + assert [p["name"] for p in body["pois"]] == ["灵隐寺"] + + def test_candidates_bad_category(): r = client.get("/poi/candidates", params={"city": "成都", "category": "drink"}) assert r.status_code == 200 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..4d3afc4 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,112 @@ +# Contributing to itravel + +欢迎参与 itravel。这个项目当前处在本地 MVP 阶段,目标是把「旅行意图输入 -> LLM 结构化规划 -> 行程表/地图联动 -> 登录保存」的核心闭环稳定跑通,再逐步补真实数据、评估、部署和 Agent 能力。 + +## 本地环境 + +建议版本: + +| 依赖 | 版本 | +| --- | --- | +| Node.js | 20 LTS 或更新 | +| npm | 随 Node 安装 | +| Python | 3.11 或更新 | +| uv | 最新稳定版 | + +macOS 可用 Homebrew 安装 `uv`: + +```bash +brew install uv +``` + +## 后端启动 + +```bash +cd backend +cp .env.example .env +uv sync +uv run uvicorn app.main:app --reload --port 8000 +``` + +`backend/.env` 可以先保留空 LLM / 高德凭证。缺少外部凭证时,规划和候选接口会走降级逻辑,仍可用于本地验证主链路。 +`AMAP_KEY` 是可选增强;不配置时不会强制调用高德 Web 服务,也不需要任何付费服务。 + +受限沙箱或 CI 环境如果不能写用户级缓存,可把 `uv` 缓存放到项目目录: + +```bash +UV_CACHE_DIR=.uv-cache uv sync +UV_CACHE_DIR=.uv-cache uv run pytest +``` + +## 前端启动 + +```bash +cd frontend +cp .env.example .env +npm install +npm run dev +``` + +默认前端地址为 `http://localhost:5173`,后端地址来自 `frontend/.env` 的 `VITE_API_BASE`,默认指向 `http://localhost:8000`。 + +受限沙箱或 CI 环境如果不能写用户级 npm cache,可把 npm 缓存放到项目目录: + +```bash +npm install --cache .npm-cache +``` + +## 验证命令 + +提交前至少运行: + +```bash +cd backend +uv run pytest + +cd ../frontend +npm test -- --run +npm run build +``` + +健康检查: + +```bash +curl http://localhost:8000/health +``` + +如果当前环境不能绑定本地端口,可以用 FastAPI `TestClient` 做进程内冒烟: + +```bash +cd backend +uv run python -c 'from fastapi.testclient import TestClient; from app.main import app; r = TestClient(app).get("/health"); print(r.status_code, r.json())' +``` + +## 常见问题 + +### `uv: command not found` + +安装 `uv` 后重新打开终端,或确认 `/opt/homebrew/bin` 在 `PATH` 中: + +```bash +brew install uv +uv --version +``` + +### `sqlite3.OperationalError: unable to open database file` + +本地默认数据库是 `backend/data/app.db`。应用启动时会自动创建父目录;如果你改了 `DATABASE_URL`,请确认 SQLite 文件所在目录可写。 + +### `Operation not permitted` with `--reload` + +`uvicorn --reload` 会启用文件监听。部分受限沙箱不允许文件监听或端口绑定。普通本地终端可以继续使用 `--reload`;受限环境可去掉 `--reload`,或用上面的 `TestClient` 健康检查替代端口冒烟。 + +### `ENOTFOUND registry.npmjs.org` or PyPI download failures + +这是网络/DNS 问题,不是项目代码问题。确认网络可访问 npm registry 和 PyPI 后重试安装命令。 + +## 贡献建议 + +- 保持改动小而清晰。一个 PR 只解决一个主题。 +- 后端行为变更优先补 pytest;前端状态逻辑优先补 Vitest。 +- 外部服务相关能力必须保留降级路径,不能因为缺 LLM 或地图 Key 导致主流程白屏。 +- 密钥只放本地 `.env`,不要提交真实 API key。 diff --git a/docs/dev_doc.md b/docs/dev_doc.md index 6cf7493..98031e2 100644 --- a/docs/dev_doc.md +++ b/docs/dev_doc.md @@ -331,11 +331,11 @@ BFF 用 `asyncio.gather` 并行发起高德/搜索/LLM 调用,单条失败不 |---|---|---| |Node\.js|≥ 20 LTS|前端构建运行时(Vite 依赖)| |Python|≥ 3\.11|后端 FastAPI 运行时| -|包管理|npm / pip\(venv\)|前后端依赖安装| +|包管理|npm / uv|前后端依赖安装| |高德 Key|Web 端 \+ JS API|POI 搜索、路径规划、地图渲染| |LLM 凭证|OpenAI 兼容端点|base\_url/api\_key/model 三件套| -**环境变量(后端 \.env)**:`OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL`(切换 Claude 4\.8 / DeepSeek V4 Pro 零代码改动)、`AMAP_KEY`(服务端)、`JWT_SECRET`、`DATABASE_URL`(本地 `sqlite:///./app.db`)。前端 `.env`:`VITE_API_BASE`(指向后端,如 http://localhost:8000)、`VITE_AMAP_JS_KEY`(JS API Key)。密钥不入库、不进 Git。 +**环境变量(后端 \.env)**:`OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL`(切换 Claude 4\.8 / DeepSeek V4 Pro 零代码改动)、`AMAP_KEY`(可选,高德 Web 服务;不填则走本地兜底)、`JWT_SECRET`、`DATABASE_URL`(本地默认 `sqlite:///./data/app.db`)。前端 `.env`:`VITE_API_BASE`(指向后端,如 http://localhost:8000)、`VITE_AMAP_JS_KEY`(JS API Key)。密钥不入库、不进 Git。 ## 9\.4 启动步骤 @@ -343,22 +343,24 @@ BFF 用 `asyncio.gather` 并行发起高德/搜索/LLM 调用,单条失败不 ```bash cd backend -python -m venv .venv && source .venv/bin/activate -pip install -r requirements.txt # fastapi uvicorn sqlmodel openai httpx cachetools argon2-cffi python-jose -python -m app.init_db # 建表(SQLite) -uvicorn app.main:app --reload --port 8000 # 启动 BFF,自带热重载 +cp .env.example .env # 按需填写 LLM / 高德凭证,可先留空 +uv sync # 创建 .venv 并安装依赖 +uv run uvicorn app.main:app --reload --port 8000 ``` **前端**(终端 2): ```bash cd frontend +cp .env.example .env # VITE_API_BASE 默认 http://localhost:8000 npm install npm run dev # Vite 启动,默认 http://localhost:5173 ``` 浏览器访问 `http://localhost:5173`,前端通过 `VITE_API_BASE` 调后端 8000 端口;后端再去聚合高德与 LLM。本地全程 http,无需 Nginx / HTTPS / 域名。 +受限沙箱或 CI 环境如果不能写用户级缓存,可使用项目内缓存:后端执行 `UV_CACHE_DIR=.uv-cache uv sync`,前端执行 `npm install --cache .npm-cache`。如果环境不能绑定本地端口,可用 FastAPI `TestClient` 对 `/health` 做进程内冒烟,具体命令见 `docs/CONTRIBUTING.md`。 + ## 9\.5 联调自检清单 - 后端 `/health` 返回 200;前端控制台无跨域报错(CORS 已在 FastAPI 放行 localhost)。