vault backup: 2026-06-12 15:55:55

This commit is contained in:
hhs
2026-06-12 15:55:55 +08:00
parent 54a57d3b60
commit ee004ff628
4 changed files with 716 additions and 6 deletions

View File

@@ -39,7 +39,8 @@ create time: 2026-06-12 11:13
## 关联笔记
- [[项目架构与技术栈]]
- [[持久化技术选型]]
- [[接口文档]]
- [[技术选型]]
- [[视觉理解]]
- [[语音交互]]
- [[成本控制]]

View File

@@ -1,16 +1,26 @@
---
tags: [数据库, PostgreSQL, 持久化, 架构设计, 拓展功能]
tags: [技术选型, 数据库, PostgreSQL, 持久化, 前端, 边缘推理, 架构设计]
create time: 2026-06-12 15:33
---
# 持久化技术选型
# 技术选型
## 概述
本文档是 [[项目架构与技术栈]] 的拓展阅读——在核心功能(实时视觉对话)跑通之后,如果需要**保存对话历史、统计用量成本、管理用户偏好**,就需要引入持久化层。本文对比主流数据库方案,解释为什么推荐 PostgreSQL以及在什么情况下应该选择其他方案
本文档是 [[项目架构与技术栈]] 的补充阅读——记录项目中各项技术的**选型过程、替代方案对比和决策理由**。技术选型没有"绝对正确",只有"更适合",所以每个选型都会列出候选方案和取舍逻辑,方便后续回顾和复盘
```mermaid
graph TD
A["技术选型"] --> B["持久化层"]
A --> C["前端边缘处理层"]
B --> B1["数据库选型: PostgreSQL"]
C --> C1["边缘推理: ONNX Runtime Web"]
C --> C2["语音检测: @ricky0123/vad-web"]
C --> C3["媒体采集: MediaDevices API"]
```
> [!info] 定位
> 这是一篇**拓展选型文档**,不阻塞 MVP 开发。MVP 阶段用 Redis 做会话存储即可;当产品需要"历史可查、成本可算"时,再引入本节方案
> 持久化部分是**拓展选型文档**,不阻塞 MVP 开发。MVP 阶段用 Redis 做会话存储即可;当产品需要"历史可查、成本可算"时,再引入持久化方案。前端边缘处理部分则是 MVP 阶段就需要确定的技术栈
## 正文
@@ -243,6 +253,97 @@ graph LR
> [!info] 写入策略
> 建议采用**异步写入**——实时对话消息先写 Redis然后异步批量刷入 PostgreSQL。这样不会因为数据库写入延迟影响对话体验。可以用 Go channel + goroutine 实现简单的异步写入队列。
---
## 前端边缘处理层选型
> [!info] 选型背景
> 项目的核心交互流程是"用户说话 → AI 看 → AI 回答"。前端需要完成**媒体采集、语音检测、轻量推理**三件事,然后才把"值得处理的数据"发给后端。这三个环节的技术选型直接影响**交互延迟和首屏加载速度**。
### 总览
| 能力 | 当前选型 | 选择理由 |
|------|---------|---------|
| 边缘推理 | **ONNX Runtime Web** | 通用推理引擎模型无关WASM 加速 |
| 语音检测 | **@ricky0123/vad-web** | 包装原生 WebRTC VAD零延迟体积极小 |
| 媒体采集 | **MediaDevices API** | 浏览器原生接口,无中间层,零依赖 |
### 边缘推理ONNX Runtime Web
在浏览器端跑 VAD 和关键帧检测,需要一个轻量推理引擎。候选方案如下:
```mermaid
graph TD
A["浏览器端推理需求"] --> B["ONNX Runtime Web"]
A --> C["TensorFlow.js"]
A --> D["MediaPipe"]
A --> E["Transformers.js"]
B --> B1["通用推理引擎"]
C --> C1["TF 生态专用"]
D --> D1["开箱即用 CV 任务"]
E --> E1["HuggingFace 生态"]
```
| 方案 | 特点 | 适用场景 |
|------|------|---------|
| **ONNX Runtime Web** | 通用推理引擎,支持任意 ONNX 模型WASM 加速 | 需要在浏览器跑**自定义模型**VAD、关键帧检测 |
| **TensorFlow.js** | Google 生态,支持 WebGL/WebGPU 加速 | 模型本身就是 TF 格式,或需要 GPU 加速 |
| **MediaPipe** | Google 出品,封装了姿态/手势/人脸等开箱即用方案 | 只需要常见 CV 任务(人脸检测、姿态估计),不需要自定义模型 |
| **Transformers.js** | Hugging Face 生态,直接跑 HuggingFace 上的模型 | 想快速集成 NLP/CV 预训练模型(如 Whisper、CLIP |
> [!question] 思考
> 为什么 ONNX Runtime Web 胜出?项目需要同时跑**两种**轻量模型——VAD 和关键帧检测,这是自定义 pipeline不是单一 CV 任务。ONNX 是跨框架的通用格式,可以在 Python 端用 PyTorch 训练,导出 ONNX然后在浏览器用统一引擎加载。TensorFlow.js 被锁死在 TF 生态MediaPipe 虽然开箱即用但灵活性不够(不能自定义模型逻辑)。**ONNX Runtime 的核心优势是"模型无关"**。
### 语音检测:@ricky0123/vad-web
VADVoice Activity Detection是交互流程的**起始触发器**——用户有没有在说话?触发必须又快又准。
| 方案 | 特点 | 适用场景 |
|------|------|---------|
| **@ricky0123/vad-web** | 基于 WebRTC VAD2KB纯前端零延迟 | 只需要"有没有人说话"的二分类判断 |
| **Web Audio API + 能量检测** | 用 AnalyserNode 计算音量 RMS阈值判断 | 极简场景,但抗噪能力差 |
| **ONNX 跑 Silero VAD** | 神经网络级 VAD准确率高但推理开销更大 | 嘈杂环境下需要更精准的检测 |
| **Picovoice Porcupine** | 商业级唤醒词引擎,支持自定义唤醒词 | 需要"嘿 Siri"式的唤醒词功能 |
```mermaid
graph LR
A["VAD 方案对比"] --> B["轻量级"]
A --> C["重量级"]
B --> B1["能量检测: 最轻, 不抗噪"]
B --> B2["vad-web: 轻量, WebRTC 原生算法"]
C --> C1["Silero VAD: 精准, 需加载 ONNX 模型"]
C --> C2["Porcupine: 商业级, 需付费"]
```
> [!question] 思考
> 用手写能量检测虽然更轻,但不抗噪(咳嗽、环境噪音都会误触发);用 Silero VAD 虽然更准,但需要加载 ONNX 模型,增加首屏时间和内存占用。**@ricky0123/vad-web 是"够用且最轻"的平衡点**——直接包装浏览器原生的 WebRTC VAD 算法C 代码编译为 WASM延迟接近零。
### 媒体采集MediaDevices API
摄像头和麦克风的采集是整个流程的源头。
| 方案 | 特点 | 适用场景 |
|------|------|---------|
| **MediaDevices API** | 浏览器原生 API零依赖直接拿 MediaStream | 标准的摄像头/麦克风采集 |
| **react-webcam 等封装库** | React 组件封装,减少胶水代码 | 快速原型,但灵活性受限 |
| **WebRTC含 getUserMedia** | 完整的点对点通信栈 | 需要浏览器之间直接传音视频(如视频会议) |
| **Capacitor/Cordova 原生桥** | 混合 App 方案,调用原生摄像头 | 目标不是浏览器而是移动 App |
> [!question] 思考
> `navigator.mediaDevices.getUserMedia()` 是所有浏览器音视频采集的**唯一标准入口**。所有上层封装库底层都是调这个 API。项目需要的是原始 MediaStream直接送进 VAD 和关键帧检测),不是封装好的组件。用封装库反而要多一层解包,**没有中间商**。
### 选型共同逻辑
这三个技术选择有一个共同的决策模式——**选择最薄的抽象层**
| 技术 | "最薄"体现在哪里 |
|------|----------------|
| **ONNX Runtime Web** | 不绑定特定框架,模型格式通用 |
| **@ricky0123/vad-web** | 包装原生 WebRTC VAD没有多余的模型加载 |
| **MediaDevices API** | 直接用浏览器原生接口,不加封装层 |
这与 [[项目架构与技术栈]] 中"前端做轻量预处理"的原则一致:前端层只需要采集和判断"有没有值得发给后端的数据"不需要复杂的模型推理能力。更重的方案TensorFlow.js、Silero VAD在后端 Go 网关和云端 AI 服务面前,属于在不该重的地方加重。
## 关联笔记
- [[项目架构与技术栈]]

View File

@@ -0,0 +1,607 @@
---
tags: [API, WebSocket, 接口设计, MVP, Go, TypeScript, 扩展性]
create time: 2026-06-12 15:41
---
# 接口文档
## 概述
本文档定义 AI 视觉对话助手的**前后端通信接口**。以 MVP 为核心目标:用 WebSocket 承载实时对话,用最少的 REST 端点支撑基础运维。**暂不实现持久化**,但通过 Repository 接口模式为后续扩展(对话历史、用量统计)预留干净的接入点。
> [!info] 设计原则
> - **WebSocket 为主**:实时对话是核心场景,所有对话数据走 WebSocket
> - **REST 为辅**:仅用于健康检查、会话管理等低频操作
> - **接口先行**:先定义契约,再填充实现——前后端可并行开发
## 正文
### 接口全景
```mermaid
graph LR
subgraph Client["浏览器"]
WS_C["WebSocket Client"]
HTTP_C["HTTP Client"]
end
subgraph Server["Go Gateway :8080"]
WS_EP["/ws"]
HEALTH_EP["/api/health"]
SESSION_EP["/api/sessions"]
end
WS_C <-->|"实时对话"| WS_EP
HTTP_C -->|"GET"| HEALTH_EP
HTTP_C <-->|"POST / DELETE"| SESSION_EP
```
### 一、WebSocket 协议
连接地址:`ws://localhost:8080/ws`
#### 1.1 连接生命周期
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
C->>S: WebSocket Upgrade 请求
S-->>C: 101 Switching Protocols
S-->>C: {"type":"connected","session_id":"..."}
Note over C,S: 连接建立,进入对话
C->>S: {"type":"query",...}
S-->>C: {"type":"stt_result",...}
S-->>C: {"type":"llm_chunk",...}
S-->>C: {"type":"llm_chunk",...}
S-->>C: {"type":"llm_done",...}
S-->>C: {"type":"tts_audio",...}
C->>S: {"type":"query",...}
Note over C,S: 持续对话...
C->>S: {"type":"ping"}
S-->>C: {"type":"pong"}
C->>S: WebSocket Close
S-->>C: WebSocket Close Ack
```
#### 1.2 消息格式约定
所有 WebSocket 消息均为 **JSON 文本帧**,统一结构:
```typescript
// 通用消息信封
interface WsMessage {
type: string; // 消息类型,必填
request_id?: string; // 可选,用于请求-响应关联
timestamp?: number; // 可选,毫秒时间戳
[key: string]: any; // 类型特定字段
}
```
#### 1.3 客户端 → 服务端消息
##### `query` —— 发起一次视觉对话
用户说完话后,客户端同时发送当前图像帧和语音片段:
```typescript
interface QueryMessage {
type: "query";
request_id: string; // 客户端生成的 UUID
image: string; // Base64 编码的 JPEG 图像(不含 data: 前缀)
audio: string; // Base64 编码的音频片段PCM 16kHz
mime_type?: string; // 音频格式,默认 "audio/pcm"
}
```
> [!question] 思考
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
##### `config` —— 更新会话配置
运行时调整 AI 行为参数,无需重建连接:
```typescript
interface ConfigMessage {
type: "config";
payload: {
tts_enabled?: boolean; // 是否开启语音合成,默认 true
detail_level?: "low" | "high"; // 图像精度,默认 "low"
language?: string; // 交互语言,默认 "zh-CN"
};
}
```
##### `interrupt` —— 打断当前回复
用户在 AI 回复过程中再次说话,打断正在进行的 LLM/TTS 流:
```typescript
interface InterruptMessage {
type: "interrupt";
request_id?: string; // 可选,指定打断哪次请求
}
```
##### `ping` —— 心跳保活
```typescript
interface PingMessage {
type: "ping";
}
```
#### 1.4 服务端 → 客户端消息
##### `connected` —— 连接建立确认
```typescript
interface ConnectedMessage {
type: "connected";
session_id: string; // 服务端生成的会话 ID
server_version: string; // 服务端版本号,如 "0.1.0"
}
```
##### `stt_result` —— 语音识别结果
LLM 推理前,先返回 STT 识别出的文本,让用户看到"我听到了什么"
```typescript
interface STTResultMessage {
type: "stt_result";
request_id: string;
text: string; // 识别出的用户语音文本
is_final: boolean; // 是否为最终结果(流式场景下可能分多段)
}
```
##### `llm_chunk` —— LLM 流式输出片段
```typescript
interface LLMChunkMessage {
type: "llm_chunk";
request_id: string;
delta: string; // 本次增量文本
role: "assistant";
}
```
##### `llm_done` —— LLM 输出完成
```typescript
interface LLMDoneMessage {
type: "llm_done";
request_id: string;
full_text: string; // 完整回复文本
tokens_used: {
prompt: number; // 输入 token 数
completion: number; // 输出 token 数
total: number;
};
model: string; // 实际使用的模型名
latency_ms: number; // 端到端延迟(毫秒)
}
```
##### `tts_audio` —— TTS 音频流片段
```typescript
interface TTSAudioMessage {
type: "tts_audio";
request_id: string;
audio: string; // Base64 编码的音频片段
mime_type: string; // "audio/mp3" 或 "audio/pcm"
is_last: boolean; // 是否为最后一片
}
```
##### `error` —— 错误通知
```typescript
interface ErrorMessage {
type: "error";
request_id?: string; // 关联的请求(可选)
code: string; // 错误码,见下方错误码表
message: string; // 人类可读的错误描述
}
```
##### `pong` —— 心跳响应
```typescript
interface PongMessage {
type: "pong";
}
```
#### 1.5 消息流时序总览
一次完整交互的消息流转:
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
Note over C: VAD 检测到语音结束
C->>S: query {image, audio}
S-->>C: stt_result {text, is_final: true}
loop LLM 流式输出
S-->>C: llm_chunk {delta: "这"}
S-->>C: llm_chunk {delta: "是一"}
S-->>C: llm_chunk {delta: "朵花..."}
end
S-->>C: llm_done {full_text, tokens_used, latency_ms}
loop TTS 音频流
S-->>C: tts_audio {audio, is_last: false}
S-->>C: tts_audio {audio, is_last: true}
end
```
### 二、REST API
MVP 阶段仅暴露最少量的 HTTP 端点:
#### 2.1 健康检查
```http
GET /api/health
```
响应:
```json
{
"status": "ok",
"version": "0.1.0",
"uptime_seconds": 3600,
"active_sessions": 42
}
```
#### 2.2 创建会话(可选)
MVP 阶段 WebSocket 连接即自动创建会话,此端点为**预留扩展**
```http
POST /api/sessions
Content-Type: application/json
{
"user_id": "optional-user-id",
"config": {
"tts_enabled": true,
"detail_level": "low",
"language": "zh-CN"
}
}
```
响应:
```json
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-06-12T15:41:00Z"
}
```
#### 2.3 销毁会话
```http
DELETE /api/sessions/{session_id}
```
响应:`204 No Content`
#### 2.4 预留端点(暂不实现)
> [!info] 后续扩展
> 引入持久化后,按需添加以下端点:
| 端点 | 方法 | 用途 | MVP 状态 |
|------|------|------|---------|
| `/api/sessions/{id}/messages` | GET | 查询对话历史 | 预留,暂不实现 |
| `/api/usage` | GET | 查询用量统计 | 预留,暂不实现 |
| `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 | 预留,暂不实现 |
### 三、数据模型
#### 3.1 Go 后端模型
```go
// ---- 核心模型MVP 实现)----
type Session struct {
ID string `json:"session_id"`
CreatedAt time.Time `json:"created_at"`
Config SessionConfig `json:"config"`
}
type SessionConfig struct {
TTSEnabled bool `json:"tts_enabled"`
DetailLevel string `json:"detail_level"` // "low" | "high"
Language string `json:"language"`
}
type QueryRequest struct {
RequestID string `json:"request_id"`
Image []byte `json:"-"` // Base64 解码后
Audio []byte `json:"-"` // Base64 解码后
MimeType string `json:"mime_type"`
}
type Message struct {
Role string `json:"role"` // "user" | "assistant"
Content string `json:"content"`
ImageURL string `json:"image_url,omitempty"`
TokensUsed int `json:"tokens_used,omitempty"`
}
```
```go
// ---- 预留模型(持久化扩展)----
// HistoryRepository 定义对话历史的存储契约
// MVP: 内存实现session 内有效,断开即丢)
// 后续: PostgreSQL 实现
type HistoryRepository interface {
SaveMessage(ctx context.Context, sessionID string, msg Message) error
GetMessages(ctx context.Context, sessionID string, limit int) ([]Message, error)
}
// UsageRepository 定义用量统计的存储契约
// MVP: 内存计数器(仅当前进程可见)
// 后续: PostgreSQL 按天聚合
type UsageRepository interface {
RecordUsage(ctx context.Context, sessionID string, usage UsageRecord) error
GetDailyUsage(ctx context.Context, userID string, days int) ([]UsageDaily, error)
}
type UsageRecord struct {
SessionID string `json:"session_id"`
LLMTokens int `json:"llm_tokens"`
STTSeconds float64 `json:"stt_seconds"`
TTSChars int `json:"tts_chars"`
EstimatedCost float64 `json:"estimated_cost"`
}
type UsageDaily struct {
Date string `json:"date"`
LLMTokens int `json:"llm_tokens"`
EstimatedCost float64 `json:"estimated_cost"`
}
```
#### 3.2 TypeScript 前端模型
```typescript
// ---- 核心模型 ----
interface Session {
sessionId: string;
createdAt: string;
config: SessionConfig;
}
interface SessionConfig {
ttsEnabled: boolean;
detailLevel: "low" | "high";
language: string;
}
interface ChatMessage {
role: "user" | "assistant";
content: string;
imageUrl?: string; // 关键帧(用户消息可附带)
timestamp: number;
tokensUsed?: number; // 仅 assistant 消息
}
// ---- WebSocket 消息联合类型 ----
type ServerMessage =
| ConnectedMessage
| STTResultMessage
| LLMChunkMessage
| LLMDoneMessage
| TTSAudioMessage
| ErrorMessage
| PongMessage;
type ClientMessage =
| QueryMessage
| ConfigMessage
| InterruptMessage
| PingMessage;
```
### 四、扩展接口设计
通过**接口Interface模式**隔离存储层MVP 用内存实现,后续替换为数据库实现——业务逻辑层零改动。
#### 4.1 Repository 接口
```mermaid
graph TD
subgraph Biz["业务逻辑层(不变)"]
ORCH["AI Orchestrator"]
SM["Session Manager"]
end
subgraph Repo["存储接口层"]
HR["HistoryRepository"]
UR["UsageRepository"]
end
subgraph Impl_MVP["MVP 实现"]
MEM_H["InMemoryHistory"]
MEM_U["InMemoryUsage"]
end
subgraph Impl_Future["后续实现"]
PG_H["PgHistory"]
PG_U["PgUsage"]
end
ORCH --> HR
ORCH --> UR
SM --> HR
HR --> MEM_H
UR --> MEM_U
HR -.->|"替换"| PG_H
UR -.->|"替换"| PG_U
```
#### 4.2 MVP 内存实现
```go
// InMemoryHistory —— MVP 阶段的对话历史实现
// 数据存在内存 map 中,连接断开即丢
type InMemoryHistory struct {
mu sync.RWMutex
sessions map[string][]Message // sessionID -> messages
}
func (h *InMemoryHistory) SaveMessage(ctx context.Context, sessionID string, msg Message) error {
h.mu.Lock()
defer h.mu.Unlock()
h.sessions[sessionID] = append(h.sessions[sessionID], msg)
return nil
}
func (h *InMemoryHistory) GetMessages(ctx context.Context, sessionID string, limit int) ([]Message, error) {
h.mu.RLock()
defer h.mu.RUnlock()
msgs := h.sessions[sessionID]
if limit > 0 && len(msgs) > limit {
msgs = msgs[len(msgs)-limit:]
}
return msgs, nil
}
```
#### 4.3 后续替换为 PostgreSQL
引入持久化时,只需新增一个实现,无需修改业务逻辑:
```go
// PgHistory —— PostgreSQL 实现(后续扩展)
type PgHistory struct {
pool *pgxpool.Pool
}
func (p *PgHistory) SaveMessage(ctx context.Context, sessionID string, msg Message) error {
_, err := p.pool.Exec(ctx,
`INSERT INTO messages (session_id, role, content, image_url, tokens_used)
VALUES ($1, $2, $3, $4, $5)`,
sessionID, msg.Role, msg.Content, msg.ImageURL, msg.TokensUsed,
)
return err
}
func (p *PgHistory) GetMessages(ctx context.Context, sessionID string, limit int) ([]Message, error) {
rows, _ := p.pool.Query(ctx,
`SELECT role, content, image_url, tokens_used
FROM messages WHERE session_id = $1
ORDER BY created_at DESC LIMIT $2`,
sessionID, limit,
)
defer rows.Close()
// ... scan and return
}
```
> [!question] 思考
> 这就是**依赖倒置原则**——业务层依赖接口(`HistoryRepository`不依赖具体实现。MVP 阶段注入 `InMemoryHistory`,上线时一行代码换成 `PgHistory`,其余逻辑完全不动。
#### 4.4 注入点示例
在应用启动时根据配置选择实现:
```go
func NewApp(cfg *Config) *App {
var history HistoryRepository
var usage UsageRepository
switch cfg.Storage.Driver {
case "postgres":
pool, _ := pgxpool.New(ctx, cfg.Storage.DSN)
history = &PgHistory{pool: pool}
usage = &PgUsage{pool: pool}
default: // "memory" — MVP 默认
history = &InMemoryHistory{sessions: make(map[string][]Message)}
usage = &InMemoryUsage{}
}
return &App{
orchestrator: NewOrchestrator(cfg.AI, history, usage),
sessionMgr: NewSessionManager(cfg.Session, history),
}
}
```
### 五、错误码定义
| 错误码 | 含义 | 客户端处理建议 |
|--------|------|--------------|
| `INVALID_MESSAGE` | 消息格式不合法 | 检查 JSON 结构,不重试 |
| `SESSION_NOT_FOUND` | 会话不存在或已过期 | 重新建立 WebSocket 连接 |
| `RATE_LIMITED` | 请求频率超限 | 延迟后重试,提示用户稍等 |
| `IMAGE_TOO_LARGE` | 图像超过 4MB 限制 | 降低分辨率或压缩质量 |
| `AUDIO_TOO_SHORT` | 音频片段 < 250ms | 忽略,等待下次语音输入 |
| `LLM_TIMEOUT` | LLM 推理超时(>10s | 提示用户重试 |
| `LLM_ERROR` | LLM 服务异常 | 提示用户重试,服务端记录日志 |
| `STT_ERROR` | 语音识别失败 | 回退到纯文本输入模式 |
| `TTS_ERROR` | 语音合成失败 | 静默回退到纯文本回复 |
| `INTERNAL_ERROR` | 服务端内部错误 | 提示用户重试 |
> [!tip] 错误处理原则
> 客户端收到 `error` 消息后,应根据错误码分别处理:可恢复的(如 `RATE_LIMITED`)自动重试;不可恢复的(如 `IMAGE_TOO_LARGE`)提示用户调整;服务端异常(如 `INTERNAL_ERROR`)记录日志并提示重试。
### 六、连接管理
#### 心跳机制
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
loop 每 30 秒
C->>S: ping
S-->>C: pong
end
Note over S: 超过 60 秒无 ping
S->>S: 判定连接断开
S->>S: 清理会话资源
```
#### 重连策略
客户端断线后按**指数退避**重连:
```typescript
function reconnect(attempt: number) {
const delay = Math.min(1000 * Math.pow(2, attempt), 30000); // 最大 30s
const jitter = Math.random() * 1000; // 随机抖动
setTimeout(() => connect(), delay + jitter);
}
// attempt: 0 → 1s, 1 → 2s, 2 → 4s, 3 → 8s, ... 最大 30s
```
## 关联笔记
- [[项目架构与技术栈]]
- [[技术选型]]
- [[项目架构与技术栈/技术名词解释]]

View File

@@ -390,4 +390,5 @@ graph TD
- [[成本控制]]
- [[用户故事]]
- [[项目架构与技术栈/技术名词解释]]
- [[持久化技术选型]]
- [[技术选型]]
- [[接口文档]]