rebuild(stage-1): establish isolated application skeleton
This commit is contained in:
@@ -18,3 +18,7 @@ htmlcov/
|
||||
test-results/
|
||||
playwright-report/
|
||||
node_modules/
|
||||
next/.venv/
|
||||
next/frontend/dist/
|
||||
next/frontend/.vite/
|
||||
next/frontend/coverage/
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 小白复盘重建版
|
||||
|
||||
本目录是依据产品规格说明书从零建立的新系统。旧系统只用于行为对照,新系统不得在运行时导入或读取旧系统源码。
|
||||
|
||||
## 本地开发
|
||||
|
||||
后端:
|
||||
|
||||
```powershell
|
||||
cd next
|
||||
py -m venv .venv
|
||||
.\.venv\Scripts\python.exe -m pip install -r requirements-dev.txt
|
||||
.\.venv\Scripts\python.exe -m uvicorn backend.main:app --host 127.0.0.1 --port 8780 --reload
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```powershell
|
||||
cd next\frontend
|
||||
npm.cmd install
|
||||
npm.cmd run dev
|
||||
```
|
||||
|
||||
浏览器访问`http://127.0.0.1:5173`。开发服务器将`/api`转发到后端8780端口。
|
||||
|
||||
## 最小质量门禁
|
||||
|
||||
```powershell
|
||||
cd next
|
||||
.\.venv\Scripts\python.exe -m ruff check backend tests
|
||||
.\.venv\Scripts\python.exe -m pytest
|
||||
cd frontend
|
||||
npm.cmd run check
|
||||
npm.cmd run test
|
||||
npm.cmd run build
|
||||
```
|
||||
|
||||
迁移约束和阶段状态见`../docs/migration/重建迁移章程.md`。
|
||||
@@ -0,0 +1 @@
|
||||
"""Xiaobai Review backend package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application composition root."""
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from backend.bootstrap.settings import Settings
|
||||
from backend.http.errors import install_error_handlers
|
||||
from backend.http.router import api_router
|
||||
|
||||
|
||||
def create_application(settings: Settings | None = None) -> FastAPI:
|
||||
runtime = settings or Settings.from_environment()
|
||||
application = FastAPI(
|
||||
title="小白复盘",
|
||||
version="0.1.0",
|
||||
docs_url="/api/docs" if runtime.debug else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
application.state.settings = runtime
|
||||
install_error_handlers(application)
|
||||
application.include_router(api_router, prefix="/api")
|
||||
return application
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Settings:
|
||||
environment: str
|
||||
debug: bool
|
||||
data_directory: Path
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> Settings:
|
||||
environment = os.getenv("APP_ENV", "development").strip().lower()
|
||||
data_directory = Path(os.getenv("APP_DATA_DIR", "data")).resolve()
|
||||
return cls(
|
||||
environment=environment,
|
||||
debug=environment == "development",
|
||||
data_directory=data_directory,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP delivery layer."""
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
def install_error_handlers(application: FastAPI) -> None:
|
||||
@application.exception_handler(Exception)
|
||||
async def unexpected_error(_request: Request, _error: Exception) -> JSONResponse:
|
||||
correlation_id = uuid.uuid4().hex
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": {
|
||||
"code": "internal_error",
|
||||
"message": "服务暂时不可用,请稍后重试。",
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.http.routes.health import router as health_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health_router)
|
||||
@@ -0,0 +1 @@
|
||||
"""Transport-only route modules."""
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
environment: str
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
def health(request: Request) -> HealthResponse:
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
environment=request.app.state.settings.environment,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from backend.bootstrap.application import create_application
|
||||
|
||||
app = create_application()
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>小白复盘</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2170
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "xiaobai-review-next-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"check": "vue-tsc -b --pretty false",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "4.0.2",
|
||||
"vue": "3.5.40",
|
||||
"vue-router": "5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "6.0.8",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "4.1.10",
|
||||
"vue-tsc": "3.3.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import BootstrapView from "./views/BootstrapView.vue";
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [{ path: "/", name: "bootstrap", component: BootstrapView }],
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
|
||||
type Health = {
|
||||
status: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
const health = ref<Health | null>(null);
|
||||
const failure = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
health.value = await api.get<Health>("/health");
|
||||
} catch (error) {
|
||||
failure.value = error instanceof Error ? error.message : "服务连接失败";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="bootstrap-view">
|
||||
<section class="bootstrap-panel" aria-live="polite">
|
||||
<h1>小白复盘</h1>
|
||||
<p v-if="health">新系统基础服务已连接({{ health.environment }})</p>
|
||||
<p v-else-if="failure" class="failure">{{ failure }}</p>
|
||||
<p v-else>正在连接基础服务</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bootstrap-view {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-16);
|
||||
}
|
||||
|
||||
.bootstrap-panel {
|
||||
width: min(100%, 420px);
|
||||
padding: var(--space-22);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-8);
|
||||
font-size: var(--font-page-title);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.failure {
|
||||
color: var(--warning);
|
||||
}
|
||||
</style>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./app/App.vue";
|
||||
import router from "./app/router";
|
||||
import "./shared/styles/tokens.css";
|
||||
import "./shared/styles/base.css";
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount("#app");
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { api, ApiError } from "./client";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("api client", () => {
|
||||
it("uses the single API prefix", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: "ok" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(api.get<{ status: string }>("/health")).resolves.toEqual({ status: "ok" });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/health",
|
||||
expect.objectContaining({ credentials: "same-origin" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes safe server errors", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { code: "unavailable", message: "暂不可用", correlation_id: "abc" },
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const promise = api.get("/health");
|
||||
await expect(promise).rejects.toBeInstanceOf(ApiError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: "暂不可用",
|
||||
status: 503,
|
||||
code: "unavailable",
|
||||
correlationId: "abc",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
type ApiErrorPayload = {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
correlation_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
readonly correlationId?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json", ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
payload.error?.message ?? "请求失败,请稍后重试。",
|
||||
response.status,
|
||||
payload.error?.code ?? "request_failed",
|
||||
payload.error?.correlation_id,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get<T>(path: string): Promise<T> {
|
||||
return request<T>(path);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--text-primary);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
background: #f4f5f7;
|
||||
|
||||
--canvas: #f4f5f7;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--primary: #2563eb;
|
||||
--warning: #b45309;
|
||||
|
||||
--space-8: 8px;
|
||||
--space-16: 16px;
|
||||
--space-22: 22px;
|
||||
--radius-card: 10px;
|
||||
--font-page-title: 17px;
|
||||
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
color: #e8eaed;
|
||||
background: #121416;
|
||||
|
||||
--canvas: #121416;
|
||||
--surface: #1b1e21;
|
||||
--border: #343a40;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #adb5bd;
|
||||
--primary: #6ca8e8;
|
||||
--warning: #e2ad58;
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:8780",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q"
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py314"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
httpx==0.28.1
|
||||
pytest==9.1.1
|
||||
ruff==0.14.14
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.141.0
|
||||
pydantic==2.13.4
|
||||
uvicorn==0.52.0
|
||||
@@ -0,0 +1,36 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.bootstrap.application import create_application
|
||||
from backend.bootstrap.settings import Settings
|
||||
|
||||
|
||||
def test_health_reports_runtime_environment(tmp_path) -> None:
|
||||
application = create_application(
|
||||
Settings(environment="test", debug=False, data_directory=tmp_path)
|
||||
)
|
||||
|
||||
with TestClient(application) as client:
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "environment": "test"}
|
||||
|
||||
|
||||
def test_unknown_failure_uses_safe_error_contract(tmp_path) -> None:
|
||||
application = create_application(
|
||||
Settings(environment="test", debug=False, data_directory=tmp_path)
|
||||
)
|
||||
|
||||
@application.get("/api/test/failure")
|
||||
def fail() -> None:
|
||||
raise RuntimeError("secret provider details")
|
||||
|
||||
with TestClient(application, raise_server_exceptions=False) as client:
|
||||
response = client.get("/api/test/failure")
|
||||
|
||||
payload = response.json()["error"]
|
||||
assert response.status_code == 500
|
||||
assert payload["code"] == "internal_error"
|
||||
assert payload["message"] == "服务暂时不可用,请稍后重试。"
|
||||
assert "secret provider details" not in response.text
|
||||
assert len(payload["correlation_id"]) == 32
|
||||
Reference in New Issue
Block a user