docs: add code architecture refactoring PRD
This commit is contained in:
416
.claude/specs/code-architecture/REFACTORING-PRD.md
Normal file
416
.claude/specs/code-architecture/REFACTORING-PRD.md
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
# 代码架构重构 PRD
|
||||||
|
|
||||||
|
> 版本: 1.0
|
||||||
|
> 日期: 2025-01-21
|
||||||
|
> 状态: 待实施
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 现状问题
|
||||||
|
|
||||||
|
经过代码审计,发现以下架构债务:
|
||||||
|
|
||||||
|
| 问题 | 位置 | 影响 |
|
||||||
|
|------|------|------|
|
||||||
|
| 缺少 Schemas 层 | Backend API | 类型不安全,文档生成差 |
|
||||||
|
| Fat Controller | `stories.py` 562行 | 难测试,职责混乱 |
|
||||||
|
| 重复加载逻辑 | Frontend 5+ 组件 | 代码冗余,维护困难 |
|
||||||
|
| 无 Repository 层 | Backend | DB 查询散落,难复用 |
|
||||||
|
|
||||||
|
### 1.2 重构目标
|
||||||
|
|
||||||
|
- **后端**:引入 Pydantic Schemas 层,分离请求/响应模型
|
||||||
|
- **前端**:抽取 Vue Composables,消除重复逻辑
|
||||||
|
- **原则**:渐进式重构,不破坏现有功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 后端重构:Schemas 层
|
||||||
|
|
||||||
|
### 2.1 目标结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
├── api/ # 路由层(瘦身)
|
||||||
|
│ ├── stories.py
|
||||||
|
│ └── ...
|
||||||
|
├── schemas/ # 新增:Pydantic 模型
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── auth.py
|
||||||
|
│ ├── story.py
|
||||||
|
│ ├── profile.py
|
||||||
|
│ ├── universe.py
|
||||||
|
│ ├── memory.py
|
||||||
|
│ ├── push_config.py
|
||||||
|
│ └── common.py # 分页、错误响应等通用结构
|
||||||
|
├── models/ # SQLAlchemy ORM(原 db/)
|
||||||
|
└── services/ # 业务逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Schema 设计规范
|
||||||
|
|
||||||
|
#### 命名约定
|
||||||
|
|
||||||
|
| 类型 | 命名模式 | 示例 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 创建请求 | `{Entity}Create` | `StoryCreate` |
|
||||||
|
| 更新请求 | `{Entity}Update` | `ProfileUpdate` |
|
||||||
|
| 响应模型 | `{Entity}Response` | `StoryResponse` |
|
||||||
|
| 列表响应 | `{Entity}ListResponse` | `StoryListResponse` |
|
||||||
|
|
||||||
|
#### 示例:Story Schema
|
||||||
|
|
||||||
|
```python
|
||||||
|
# schemas/story.py
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class StoryCreate(BaseModel):
|
||||||
|
"""创建故事请求"""
|
||||||
|
type: Literal["keywords", "enhance", "storybook"]
|
||||||
|
data: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
education_theme: str | None = None
|
||||||
|
child_profile_id: str | None = None
|
||||||
|
universe_id: str | None = None
|
||||||
|
|
||||||
|
class StoryResponse(BaseModel):
|
||||||
|
"""故事响应"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
story_text: str | None
|
||||||
|
pages: list[dict] | None
|
||||||
|
image_url: str | None
|
||||||
|
mode: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True # 支持 ORM 模型转换
|
||||||
|
|
||||||
|
class StoryListResponse(BaseModel):
|
||||||
|
"""故事列表响应"""
|
||||||
|
stories: list[StoryResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 迁移步骤
|
||||||
|
|
||||||
|
| 阶段 | 任务 | 文件 |
|
||||||
|
|------|------|------|
|
||||||
|
| P1 | 创建 `schemas/common.py` | 分页、错误响应 |
|
||||||
|
| P2 | 创建 `schemas/story.py` | 故事相关模型 |
|
||||||
|
| P3 | 创建 `schemas/profile.py` | 档案相关模型 |
|
||||||
|
| P4 | 创建 `schemas/universe.py` | 宇宙相关模型 |
|
||||||
|
| P5 | 创建 `schemas/auth.py` | 认证相关模型 |
|
||||||
|
| P6 | 创建 `schemas/memory.py` | 记忆相关模型 |
|
||||||
|
| P7 | 创建 `schemas/push_config.py` | 推送配置模型 |
|
||||||
|
| P8 | 重构 `api/stories.py` | 使用新 schemas |
|
||||||
|
| P9 | 重构其他 API 文件 | 逐个迁移 |
|
||||||
|
|
||||||
|
### 2.4 验收标准
|
||||||
|
|
||||||
|
- [ ] 所有 API endpoint 使用 `response_model` 参数
|
||||||
|
- [ ] 请求体使用 Pydantic 模型而非 `Body(...)`
|
||||||
|
- [ ] OpenAPI 文档自动生成完整类型
|
||||||
|
- [ ] 现有测试全部通过
|
||||||
|
- [ ] `ruff check` 无错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 前端重构:Composables
|
||||||
|
|
||||||
|
### 3.1 目标结构
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── composables/ # 新增:可复用逻辑
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── useAsyncData.ts # 异步数据加载
|
||||||
|
│ ├── useFormValidation.ts # 表单验证
|
||||||
|
│ └── useDateFormat.ts # 日期格式化
|
||||||
|
├── components/
|
||||||
|
├── stores/
|
||||||
|
└── views/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Composable 设计
|
||||||
|
|
||||||
|
#### 3.2.1 useAsyncData
|
||||||
|
|
||||||
|
**用途**:统一处理 API 数据加载的 loading/error 状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useAsyncData.ts
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
interface AsyncDataOptions<T> {
|
||||||
|
immediate?: boolean // 立即执行,默认 true
|
||||||
|
initialData?: T // 初始数据
|
||||||
|
onError?: (e: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AsyncDataReturn<T> {
|
||||||
|
data: Ref<T | null>
|
||||||
|
loading: Ref<boolean>
|
||||||
|
error: Ref<string>
|
||||||
|
execute: () => Promise<void>
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsyncData<T>(
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
options: AsyncDataOptions<T> = {}
|
||||||
|
): AsyncDataReturn<T> {
|
||||||
|
const { immediate = true, initialData = null, onError } = options
|
||||||
|
|
||||||
|
const data = ref<T | null>(initialData) as Ref<T | null>
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function execute() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
data.value = await fetcher()
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : '加载失败'
|
||||||
|
error.value = message
|
||||||
|
onError?.(e instanceof Error ? e : new Error(message))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
data.value = initialData
|
||||||
|
loading.value = false
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, loading, error, execute, reset }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// views/MyStories.vue(重构前 40 行 → 重构后 10 行)
|
||||||
|
import { useAsyncData } from '@/composables'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
|
||||||
|
const { data: response, loading, error } = useAsyncData(
|
||||||
|
() => api.get<StoryListResponse>('/api/stories')
|
||||||
|
)
|
||||||
|
const stories = computed(() => response.value?.stories ?? [])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.2 useFormValidation
|
||||||
|
|
||||||
|
**用途**:统一表单验证逻辑
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useFormValidation.ts
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
|
||||||
|
type ValidationRule = (value: unknown) => string | true
|
||||||
|
|
||||||
|
interface FieldRules {
|
||||||
|
[field: string]: ValidationRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormValidation<T extends Record<string, unknown>>(
|
||||||
|
initialData: T,
|
||||||
|
rules: FieldRules
|
||||||
|
) {
|
||||||
|
const form = reactive({ ...initialData })
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
let valid = true
|
||||||
|
for (const [field, fieldRules] of Object.entries(rules)) {
|
||||||
|
const value = form[field]
|
||||||
|
for (const rule of fieldRules) {
|
||||||
|
const result = rule(value)
|
||||||
|
if (result !== true) {
|
||||||
|
errors[field] = result
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
errors[field] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
Object.assign(form, initialData)
|
||||||
|
Object.keys(errors).forEach(k => errors[k] = '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { form, errors, validate, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常用验证规则
|
||||||
|
export const required = (msg = '此字段必填') =>
|
||||||
|
(v: unknown) => (v !== null && v !== undefined && v !== '') || msg
|
||||||
|
|
||||||
|
export const minLength = (min: number, msg?: string) =>
|
||||||
|
(v: unknown) => (typeof v === 'string' && v.length >= min) || msg || `至少 ${min} 个字符`
|
||||||
|
|
||||||
|
export const maxLength = (max: number, msg?: string) =>
|
||||||
|
(v: unknown) => (typeof v === 'string' && v.length <= max) || msg || `最多 ${max} 个字符`
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.3 useDateFormat
|
||||||
|
|
||||||
|
**用途**:统一日期格式化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useDateFormat.ts
|
||||||
|
export function useDateFormat() {
|
||||||
|
function formatDate(dateStr: string | Date, format: 'date' | 'datetime' | 'relative' = 'date'): string {
|
||||||
|
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
|
||||||
|
|
||||||
|
if (format === 'relative') {
|
||||||
|
return formatRelative(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
if (format === 'date') {
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(date: Date): string {
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000)
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000)
|
||||||
|
|
||||||
|
if (diffMins < 1) return '刚刚'
|
||||||
|
if (diffMins < 60) return `${diffMins} 分钟前`
|
||||||
|
if (diffHours < 24) return `${diffHours} 小时前`
|
||||||
|
if (diffDays < 7) return `${diffDays} 天前`
|
||||||
|
return formatDate(date, 'date')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { formatDate, formatRelative }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 迁移步骤
|
||||||
|
|
||||||
|
| 阶段 | 任务 | 影响文件 |
|
||||||
|
|------|------|----------|
|
||||||
|
| P1 | 创建 `composables/useAsyncData.ts` | 新文件 |
|
||||||
|
| P2 | 创建 `composables/useDateFormat.ts` | 新文件 |
|
||||||
|
| P3 | 创建 `composables/useFormValidation.ts` | 新文件 |
|
||||||
|
| P4 | 创建 `composables/index.ts` | 统一导出 |
|
||||||
|
| P5 | 重构 `views/MyStories.vue` | 使用 useAsyncData |
|
||||||
|
| P6 | 重构 `views/ChildProfiles.vue` | 使用 useAsyncData |
|
||||||
|
| P7 | 重构 `views/Universes.vue` | 使用 useAsyncData |
|
||||||
|
| P8 | 重构 `components/CreateStoryModal.vue` | 使用 useFormValidation |
|
||||||
|
| P9 | 同步重构 `admin-frontend/` | 复制 composables |
|
||||||
|
|
||||||
|
### 3.4 验收标准
|
||||||
|
|
||||||
|
- [ ] `composables/` 目录包含 3 个核心 composable
|
||||||
|
- [ ] 至少 3 个 view 组件使用 `useAsyncData`
|
||||||
|
- [ ] `CreateStoryModal` 使用 `useFormValidation`
|
||||||
|
- [ ] `npm run build` 无类型错误
|
||||||
|
- [ ] 现有功能正常运行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 重构引入 bug | 高 | 每个阶段运行测试 |
|
||||||
|
| 类型不兼容 | 中 | 使用 `from_attributes = True` |
|
||||||
|
| 前端构建失败 | 中 | 逐文件迁移,及时 commit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 时间估算
|
||||||
|
|
||||||
|
| 模块 | 工作量 | 预计时间 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Backend Schemas | 10 个新文件 + 9 个 API 重构 | 3-4 小时 |
|
||||||
|
| Frontend Composables | 4 个新文件 + 6 个组件重构 | 2-3 小时 |
|
||||||
|
| 测试验证 | 全量回归 | 1 小时 |
|
||||||
|
| **总计** | | **6-8 小时** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 后续迭代
|
||||||
|
|
||||||
|
本次重构完成后,可继续:
|
||||||
|
|
||||||
|
1. **Repository 层**:抽象 DB 查询逻辑
|
||||||
|
2. **Service 层瘦身**:拆分 `provider_router.py` (433行)
|
||||||
|
3. **前端共享包**:`frontend` 和 `admin-frontend` 共用 UI 组件
|
||||||
|
4. **API 版本化**:引入 `/api/v1/` 前缀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:文件清单
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/schemas/
|
||||||
|
├── __init__.py
|
||||||
|
├── common.py
|
||||||
|
├── auth.py
|
||||||
|
├── story.py
|
||||||
|
├── profile.py
|
||||||
|
├── universe.py
|
||||||
|
├── memory.py
|
||||||
|
└── push_config.py
|
||||||
|
|
||||||
|
frontend/src/composables/
|
||||||
|
├── index.ts
|
||||||
|
├── useAsyncData.ts
|
||||||
|
├── useFormValidation.ts
|
||||||
|
└── useDateFormat.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/api/
|
||||||
|
├── stories.py
|
||||||
|
├── profiles.py
|
||||||
|
├── universes.py
|
||||||
|
├── memories.py
|
||||||
|
├── push_configs.py
|
||||||
|
├── reading_events.py
|
||||||
|
└── auth.py
|
||||||
|
|
||||||
|
frontend/src/views/
|
||||||
|
├── MyStories.vue
|
||||||
|
├── ChildProfiles.vue
|
||||||
|
├── Universes.vue
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
frontend/src/components/
|
||||||
|
└── CreateStoryModal.vue
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user