feat: Phase 0 — monorepo 骨架 + 全表迁移 + FastAPI/Next 骨架 + CI

- uv(workspace) + pnpm monorepo;docker-compose(pg+api+web)
- SQLAlchemy 16 MVP 表 + Alembic 初版迁移(无漂移,users stub)
- FastAPI 骨架:统一错误信封(带 request_id) + structlog + /jobs/:id + OpenAPI
- Next.js 骨架:纸感主题 token + OpenAPI→TS 客户端代码生成(gen:api)
- CI(ruff/mypy/pytest + pg service + alembic 漂移校验)
- 四份设计规格(PRODUCT/UX/ARCHITECTURE/DEV_PLAN) + CLAUDE.md
This commit is contained in:
Yaojia Wang
2026-06-18 11:38:28 +02:00
commit d3dc620a71
74 changed files with 12960 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
describe("api base url", () => {
it("falls back to localhost when env unset", () => {
const base = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
expect(base).toMatch(/^https?:\/\//);
});
});

View File

@@ -0,0 +1,8 @@
import createClient from "openapi-fetch";
import type { paths } from "./schema";
import { API_BASE_PUBLIC } from "./config";
// 类型安全的后端客户端schema.d.ts 由 `pnpm gen:api` 从后端 OpenAPI 生成)。
// 浏览器侧使用 NEXT_PUBLIC_API_BASE服务端读取请走 lib/api/server.ts。
export const api = createClient<paths>({ baseUrl: API_BASE_PUBLIC });

View File

@@ -0,0 +1,8 @@
// 后端基址:服务端组件用 INTERNAL容器内 service 名),客户端用 PUBLIC浏览器可达
// 两者皆可缺省回退到本地开发地址。
export const API_BASE_PUBLIC =
process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
export function serverApiBase(): string {
return process.env.API_BASE_INTERNAL ?? API_BASE_PUBLIC;
}

987
apps/web/lib/api/schema.d.ts vendored Normal file
View File

@@ -0,0 +1,987 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Root */
get: operations["root__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Health */
get: operations["health_health_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/jobs/{job_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Job */
get: operations["get_job_jobs__job_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Projects */
get: operations["list_projects_projects_get"];
put?: never;
/** Create Project */
post: operations["create_project_projects_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Project */
get: operations["get_project_projects__project_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/draft": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Save Draft
* @description 自动保存:幂等 upsert 草稿(同章节覆盖同一行,版次不爆炸)。
*/
put: operations["save_draft_projects__project_id__chapters__chapter_no__draft_put"];
/**
* Stream Draft
* @description 流式写章草稿:组装记忆 → 网关流 → 归一为 SSE 事件 → text/event-stream。
*/
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/review": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Review Chapter
* @description 续审SSE组审稿上下文 → 跑审稿子图 → 归一为 section/conflict/done 事件。
*
* 提交边界:网关 ledger + collect 经 review_repo.record 均只 flush端点在**流耗尽后**
* `await session.commit()`(镜像 draft 端点,否则记账/留痕静默丢失)。
*/
post: operations["review_chapter_projects__project_id__chapters__chapter_no__review_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/reviews": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Reviews
* @description 审稿历史(新→旧):供前端审稿页加载既往审稿留痕 + 裁决。
*/
get: operations["list_reviews_projects__project_id__chapters__chapter_no__reviews_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/accept": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Accept Chapter
* @description 验收事务 + 冲突 gate§5.5gate事务前→ 终稿提炼 digest事务外R2
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
*/
post: operations["accept_chapter_projects__project_id__chapters__chapter_no__accept_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings/providers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Providers */
get: operations["list_providers_settings_providers_get"];
/** Upsert Providers */
put: operations["upsert_providers_settings_providers_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings/providers/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Test Connection */
post: operations["test_connection_settings_providers_test_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/**
* AcceptRequest
* @description POST /projects/:id/chapters/:no/accept裁决清单 + 可能改过的终稿。
*/
AcceptRequest: {
/**
* Final Text
* @description 作者裁决/改稿后的最终验收文本
*/
final_text: string;
/**
* Decisions
* @description 对最近审稿每个冲突的裁决每冲突必有其一R5
*/
decisions?: components["schemas"]["ConflictDecision"][];
};
/**
* AcceptResponse
* @description 验收「本次将更新」清单ARCH §7.2 写回结果)。
*/
AcceptResponse: {
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Chapter No */
chapter_no: number;
/**
* Accepted Version
* @description 晋升到的 accepted 版次max+1
*/
accepted_version: number;
/**
* Digest Added
* @description 是否新增了一行 chapter_digests
*/
digest_added: boolean;
/**
* Decisions Recorded
* @description 本次写回的裁决条数
*/
decisions_recorded: number;
/**
* Review Id
* @description 写回裁决的审稿留痕行 id
*/
review_id?: string | null;
};
/**
* CapabilitiesView
* @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。
*/
CapabilitiesView: {
/**
* Structured Output
* @default false
*/
structured_output: boolean;
/**
* Prefix Cache
* @default false
*/
prefix_cache: boolean;
/**
* Thinking
* @default false
*/
thinking: boolean;
};
/**
* ConflictDecision
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
*/
ConflictDecision: {
/**
* Conflict Index
* @description 冲突在最近审稿 conflicts 列表中的下标
*/
conflict_index: number;
/**
* Verdict
* @description 采纳改法 / 忽略 / 手改
* @enum {string}
*/
verdict: "accept" | "ignore" | "manual";
/**
* Note
* @description 可选裁决备注(如手改说明)
*/
note?: string | null;
};
/**
* DraftResponse
* @description 草稿保存结果(脱敏:只回元信息 + 长度,不回灌正文以外的衍生)。
*/
DraftResponse: {
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Chapter No */
chapter_no: number;
/** Volume */
volume: number;
/** Status */
status: string;
/** Version */
version: number;
/** Length */
length: number;
};
/**
* DraftSaveRequest
* @description PUT /projects/:id/chapters/:no/draft自动保存草稿正文。
*/
DraftSaveRequest: {
/** Text */
text: string;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/**
* ProjectCreateRequest
* @description POST /projects立项向导字段owner_id 由后端补 stub不入参
*/
ProjectCreateRequest: {
/** Title */
title: string;
/** Genre */
genre?: string | null;
/** Logline */
logline?: string | null;
/** Premise */
premise?: string | null;
/** Theme */
theme?: string | null;
/** Selling Points */
selling_points?: unknown[];
/** Structure */
structure?: string | null;
};
/**
* ProjectListResponse
* @description GET /projects项目列表。
*/
ProjectListResponse: {
/** Projects */
projects?: components["schemas"]["ProjectResponse"][];
};
/**
* ProjectResponse
* @description 项目视图(创建/列表/详情共用)。
*/
ProjectResponse: {
/**
* Id
* Format: uuid
*/
id: string;
/** Title */
title: string;
/** Genre */
genre?: string | null;
/** Logline */
logline?: string | null;
/** Premise */
premise?: string | null;
/** Theme */
theme?: string | null;
/** Selling Points */
selling_points?: unknown[];
/** Structure */
structure?: string | null;
};
/**
* ProviderCredentialInput
* @description 单条提供商凭据写入。
*/
ProviderCredentialInput: {
/** Provider */
provider: string;
/** Api Key */
api_key: string;
};
/**
* ProviderView
* @description 已配置提供商(掩码视图)。
*/
ProviderView: {
/** Provider */
provider: string;
/** Masked Key */
masked_key: string;
};
/**
* ProvidersResponse
* @description GET/PUT 响应:已配置提供商(掩码)+ 当前档位路由。
*/
ProvidersResponse: {
/** Providers */
providers?: components["schemas"]["ProviderView"][];
/** Tier Routing */
tier_routing?: components["schemas"]["TierRoutingView"][];
};
/**
* ProvidersUpsertRequest
* @description PUT 请求:可同时 upsert 若干凭据与档位路由(幂等)。
*/
ProvidersUpsertRequest: {
/** Credentials */
credentials?: components["schemas"]["ProviderCredentialInput"][];
/** Tier Routing */
tier_routing?: components["schemas"]["TierRoutingInput"][];
};
/**
* ReviewHistoryItem
* @description 单条审稿留痕GET .../reviews 历史项snake_case
*/
ReviewHistoryItem: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Chapter No */
chapter_no: number;
/** Chapter Version */
chapter_version?: number | null;
/** Conflicts */
conflicts?: {
[key: string]: unknown;
}[];
/** Foreshadow Sug */
foreshadow_sug?: {
[key: string]: unknown;
}[];
/** Style */
style?: {
[key: string]: unknown;
} | null;
/** Pace */
pace?: {
[key: string]: unknown;
} | null;
/** Health Score */
health_score?: number | null;
/** Decisions */
decisions?: {
[key: string]: unknown;
} | null;
};
/**
* ReviewHistoryResponse
* @description GET /projects/:id/chapters/:no/reviews审稿历史新→旧
*/
ReviewHistoryResponse: {
/** Reviews */
reviews?: components["schemas"]["ReviewHistoryItem"][];
};
/**
* ReviewRequest
* @description POST /projects/:id/chapters/:no/review可选携带待审草稿正文。
*
* 不传 `draft` 时端点回退到已保存的草稿chapter_repo
*/
ReviewRequest: {
/** Draft */
draft?: string | null;
};
/**
* TestConnectionRequest
* @description POST /test最小探测请求。
*/
TestConnectionRequest: {
/** Provider */
provider: string;
};
/**
* TestConnectionResponse
* @description POST /test 响应:连通性 + 能力矩阵。
*/
TestConnectionResponse: {
/** Provider */
provider: string;
/** Ok */
ok: boolean;
capabilities: components["schemas"]["CapabilitiesView"];
};
/**
* TierRoutingInput
* @description 单条档位路由写入。
*/
TierRoutingInput: {
/** Tier */
tier: string;
/** Provider */
provider: string;
/** Model */
model: string;
/** Fallback */
fallback?: string[];
};
/**
* TierRoutingView
* @description 档位 → provider:model 路由(含回退链)。
*/
TierRoutingView: {
/** Tier */
tier: string;
/** Provider */
provider: string;
/** Model */
model: string;
/** Fallback */
fallback?: string[];
};
/** ValidationError */
ValidationError: {
/** Location */
loc: (string | number)[];
/** Message */
msg: string;
/** Error Type */
type: string;
/** Input */
input?: unknown;
/** Context */
ctx?: Record<string, never>;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
root__get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
health_health_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
get_job_jobs__job_id__get: {
parameters: {
query?: never;
header?: never;
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_projects_projects_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectListResponse"];
};
};
};
};
create_project_projects_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProjectCreateRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_project_projects__project_id__get: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
save_draft_projects__project_id__chapters__chapter_no__draft_put: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DraftSaveRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DraftResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
stream_draft_projects__project_id__chapters__chapter_no__draft_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
review_chapter_projects__project_id__chapters__chapter_no__review_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ReviewRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_reviews_projects__project_id__chapters__chapter_no__reviews_get: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReviewHistoryResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
accept_chapter_projects__project_id__chapters__chapter_no__accept_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AcceptRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AcceptResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_providers_settings_providers_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProvidersResponse"];
};
};
};
};
upsert_providers_settings_providers_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProvidersUpsertRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProvidersResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
test_connection_settings_providers_test_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TestConnectionRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TestConnectionResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}

View File

@@ -0,0 +1,37 @@
import { serverApiBase } from "./config";
import type {
ProjectListResponse,
ProjectResponse,
ProvidersResponse,
ReviewHistoryResponse,
} from "./types";
// 服务端只读取数据Server Components。失败时抛出由页面边界处理。
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
if (!res.ok) {
throw new Error(`请求失败 ${res.status}: ${path}`);
}
return (await res.json()) as T;
}
export async function fetchProjects(): Promise<ProjectListResponse> {
return getJson<ProjectListResponse>("/projects");
}
export async function fetchProject(projectId: string): Promise<ProjectResponse> {
return getJson<ProjectResponse>(`/projects/${projectId}`);
}
export async function fetchProviders(): Promise<ProvidersResponse> {
return getJson<ProvidersResponse>("/settings/providers");
}
export async function fetchReviews(
projectId: string,
chapterNo: number,
): Promise<ReviewHistoryResponse> {
return getJson<ReviewHistoryResponse>(
`/projects/${projectId}/chapters/${chapterNo}/reviews`,
);
}

27
apps/web/lib/api/types.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { components } from "./schema";
// 复用后端 OpenAPI 生成的 schema 类型绝不手写共享类型CLAUDE.md 命名契约)。
export type ProjectResponse = components["schemas"]["ProjectResponse"];
export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"];
export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
export type DraftResponse = components["schemas"]["DraftResponse"];
export type ProvidersResponse = components["schemas"]["ProvidersResponse"];
export type ProviderView = components["schemas"]["ProviderView"];
export type TierRoutingView = components["schemas"]["TierRoutingView"];
export type ProvidersUpsertRequest =
components["schemas"]["ProvidersUpsertRequest"];
export type TestConnectionRequest =
components["schemas"]["TestConnectionRequest"];
export type TestConnectionResponse =
components["schemas"]["TestConnectionResponse"];
export type CapabilitiesView = components["schemas"]["CapabilitiesView"];
// M2 审/裁/验收C3 扩)。
export type ReviewRequest = components["schemas"]["ReviewRequest"];
export type ReviewHistoryItem = components["schemas"]["ReviewHistoryItem"];
export type ReviewHistoryResponse =
components["schemas"]["ReviewHistoryResponse"];
export type ConflictDecision = components["schemas"]["ConflictDecision"];
export type AcceptRequest = components["schemas"]["AcceptRequest"];
export type AcceptResponse = components["schemas"]["AcceptResponse"];