feat(govai): 0617 优化首批 — 安全/私有化/深度研究/服务层/可观测性
借鉴 odysseus 的能力设计,全程净室实现、零 AGPL 代码、不引入 AGPL 依赖。
T1 提示注入防护: pkg/promptguard 包裹外部/知识库内容为不可信数据,buildMessages 移出 system 指令区。 T2 安全 CI: .github/workflows(ci+security: govulncheck/gitleaks/actionlint/hadolint/trivy)+dependabot+.hadolint.yaml;go.mod 加 toolchain go1.25.11 修复 20 个 stdlib CVE。 T3 管理员 2FA: 迁移 000016 + RFC6238 TOTP/备份码(pkg/auth, 零依赖) + 登录流程集成(后端)。 T4 本地模型: LLM/embedding 支持本地 vLLM/Ollama(OpenAI 兼容, 鉴权头条件发送, NoAuth) + docs/local-deploy.md。 T6 深度研究: 迁移 000017 + Python research-worker(净室多步流水线, 检索避开 SearXNG) + Go research 服务/handler/路由。 T7 service 层: 新增 internal/service/{research,twofa}, 2FA 业务逻辑从胖 handler 下沉, 接口注入可单测。 T10 缓存/可观测性: internal/cache(Redis+内存, 优雅降级) 接入 store 热点列表; Prometheus 指标+/metrics; docs/openapi.yaml。 验证: go build/vet/test ./... 全绿(8 包); research-worker 12 单测过; 真实 PG 应用迁移并烟测。
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# 本地模型部署(私有化 / 内网)指南
|
||||
|
||||
面向数据主权、信创、等保要求的场景:把对话推理与向量化(RAG)全部切到**本地 / 内网**的模型服务,数据全程不出公网。GovAi 后端通过统一的 OpenAI 兼容接口对接,无需改代码,仅配置环境变量即可。
|
||||
|
||||
> 本指南为 GovAi 自行编写。所涉开源组件(Ollama、vLLM 等)各自遵循其许可证,请按各自条款使用。
|
||||
|
||||
## 一、原理
|
||||
|
||||
- 对话推理:GovAi 的 `LLM_PROVIDER=local` 会启用一个 **OpenAI 兼容** 的本地 provider(`/v1/chat/completions`,支持流式 SSE)。
|
||||
- 向量化:`EMBEDDING_BASE_URL` 指向本地 `/v1/embeddings` 即可;本地无鉴权时设 `EMBEDDING_NO_AUTH=true`。
|
||||
- 凡是暴露 OpenAI 兼容接口的本地引擎(vLLM、Ollama、llama.cpp server、LM Studio、SGLang 等)都可对接。
|
||||
|
||||
## 二、起本地模型服务(任选其一)
|
||||
|
||||
### 方式 A:Ollama(最简单,适合单机试点)
|
||||
|
||||
```bash
|
||||
# 安装后拉起模型(示例为通义千问 7B)
|
||||
ollama pull qwen2.5:7b
|
||||
ollama pull bge-m3 # 向量模型(RAG 用)
|
||||
# Ollama 默认在 11434 提供服务,并暴露 OpenAI 兼容端点 /v1
|
||||
```
|
||||
|
||||
- Chat 端点:`http://127.0.0.1:11434/v1`
|
||||
- Embedding 端点:`http://127.0.0.1:11434/v1`
|
||||
- Ollama 忽略鉴权,密钥留空即可。
|
||||
|
||||
### 方式 B:vLLM(吞吐更高,适合生产 GPU 服务器)
|
||||
|
||||
```bash
|
||||
# 以 OpenAI 兼容服务启动(示例)
|
||||
python -m vllm.entrypoints.openai.api_server \
|
||||
--model Qwen/Qwen2.5-7B-Instruct \
|
||||
--host 0.0.0.0 --port 8000
|
||||
# 如需鉴权:追加 --api-key <你的密钥>,并在下方填入 LOCAL_LLM_API_KEY
|
||||
```
|
||||
|
||||
- Chat 端点:`http://127.0.0.1:8000/v1`
|
||||
- 向量化建议另起一个 embedding 服务(如用 vLLM/`text-embeddings-inference` 部署 `bge-m3`)。
|
||||
|
||||
## 三、配置 GovAi(`.env`)
|
||||
|
||||
```bash
|
||||
# 对话推理切到本地
|
||||
LLM_PROVIDER=local
|
||||
LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1 # 或 vLLM 的 http://127.0.0.1:8000/v1
|
||||
LOCAL_LLM_MODEL=qwen2.5:7b # 与本地实际模型名一致
|
||||
LOCAL_LLM_API_KEY= # Ollama 留空;vLLM 若设了 --api-key 则填
|
||||
|
||||
# 向量化切到本地(RAG 全链路离线)
|
||||
EMBEDDING_BASE_URL=http://127.0.0.1:11434/v1
|
||||
EMBEDDING_MODEL=bge-m3
|
||||
EMBEDDING_DIMENSIONS=1024 # 必须与模型输出维度一致,详见下文
|
||||
EMBEDDING_NO_AUTH=true # 本地无鉴权
|
||||
```
|
||||
|
||||
重启后端(`make dev-api` 或容器)后即生效。云端与本地可共存:`openai`/`anthropic`/`local` 三个 provider 同时注册,由 `LLM_PROVIDER` 决定默认使用哪个。
|
||||
|
||||
## 四、⚠️ 向量维度一致性(重要)
|
||||
|
||||
`EMBEDDING_DIMENSIONS` 必须同时满足三方一致:
|
||||
|
||||
1. 本地 embedding 模型的实际输出维度(如 `bge-m3` = 1024,`nomic-embed-text` = 768);
|
||||
2. `pgvector` 中 `knowledge_chunks.embedding` 列声明的维度;
|
||||
3. 已入库的历史向量维度。
|
||||
|
||||
**更换 embedding 模型导致维度变化时**,需要新建/调整向量列维度,并对知识库**重新向量化**:
|
||||
|
||||
```bash
|
||||
# 清空旧向量后,通过接口或工具重嵌入
|
||||
# POST /api/v1/knowledge/reembed (或 server/cmd/embed-chunks)
|
||||
```
|
||||
|
||||
维度不一致会导致向量检索报错或失效(此时系统会优雅降级为关键词检索)。
|
||||
|
||||
## 五、验证
|
||||
|
||||
1. 启动本地模型服务与后端。
|
||||
2. 在应用商店打开任一对话型应用,发送一条消息,确认**流式逐字输出**正常。
|
||||
3. 打开一个绑定知识库的应用,提问知识库内问题,确认能检索到文献并标注来源(RAG 生效)。
|
||||
4. **离线验证**:临时切断后端所在主机的公网出口(仅保留到本地模型服务的内网连通),重复第 2、3 步,确认对话与 RAG 仍可完成——即数据不出网。
|
||||
|
||||
## 六、安全提示
|
||||
|
||||
- 本地模型端口(11434 / 8000 等)只在内网开放,**不要暴露到公网**。
|
||||
- 生产建议在后端与模型服务之间加内网网关/反向代理,并对模型服务启用鉴权(vLLM `--api-key`)。
|
||||
- 私有化下仍建议开启管理员 2FA(见 0617task.md T3)与提示注入防护(T1)。
|
||||
@@ -0,0 +1,447 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 政智通 GovAI Portal API
|
||||
description: >
|
||||
政务 AI 应用平台后端 API。所有业务响应使用统一信封 `{code,message,data}`,
|
||||
`code=0` 表示成功。认证使用 JWT(Bearer 头或 access_token Cookie)。
|
||||
本规范覆盖主要端点,随实现演进补充。
|
||||
version: "1.0.0"
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080/api/v1
|
||||
description: 本地开发
|
||||
|
||||
tags:
|
||||
- name: auth
|
||||
description: 认证与两步验证
|
||||
- name: store
|
||||
description: 应用商店(公开只读)
|
||||
- name: apps
|
||||
description: 应用使用(对话/补全)
|
||||
- name: research
|
||||
description: 深度研究(综合研判)
|
||||
- name: ppt
|
||||
description: PPT 生成
|
||||
- name: knowledge
|
||||
description: 知识库
|
||||
- name: ops
|
||||
description: 运维(健康检查 / 指标)
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: access_token
|
||||
schemas:
|
||||
ApiResponse:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
description: 0 为成功,其它为业务错误码
|
||||
example: 0
|
||||
message:
|
||||
type: string
|
||||
example: success
|
||||
data:
|
||||
nullable: true
|
||||
description: 业务数据,结构随接口而定
|
||||
LoginRequest:
|
||||
type: object
|
||||
required: [email, password]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string, format: password }
|
||||
org_id: { type: string, description: 机构 ID(可选) }
|
||||
totp_code: { type: string, description: 已开启 2FA 时提供 }
|
||||
backup_code: { type: string, description: 备份码(TOTP 不可用时) }
|
||||
ResearchCreateRequest:
|
||||
type: object
|
||||
required: [topic]
|
||||
properties:
|
||||
topic: { type: string, description: 研究题目/问题 }
|
||||
app_id: { type: string }
|
||||
config:
|
||||
type: object
|
||||
properties:
|
||||
max_steps: { type: integer, default: 4 }
|
||||
max_sources: { type: integer, default: 6 }
|
||||
language: { type: string, default: zh }
|
||||
ResearchTask:
|
||||
type: object
|
||||
properties:
|
||||
task_id: { type: string }
|
||||
topic: { type: string }
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, planning, searching, reading, synthesizing, completed, failed, canceled]
|
||||
progress: { type: integer, minimum: 0, maximum: 100 }
|
||||
report: { type: string, nullable: true, description: 完成后的 Markdown 报告 }
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
title: { type: string }
|
||||
url: { type: string }
|
||||
snippet: { type: string }
|
||||
tokens_used: { type: integer }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
|
||||
paths:
|
||||
/auth/register:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 注册
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, email, password]
|
||||
properties:
|
||||
name: { type: string }
|
||||
email: { type: string, format: email }
|
||||
password: { type: string, minLength: 6 }
|
||||
responses:
|
||||
"201": { description: 注册成功, content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } }
|
||||
"409": { description: 邮箱已注册 }
|
||||
|
||||
/auth/login:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 登录(支持 2FA)
|
||||
description: 已开启 2FA 的账号在密码正确但未带验证码时返回 `code=40110`,前端据此引导输入验证码后重试。
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/LoginRequest" }
|
||||
responses:
|
||||
"200": { description: 登录成功, content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } }
|
||||
"401": { description: 凭据错误 / 需要或验证码错误(40110 需要 2FA、40111 验证码错误) }
|
||||
|
||||
/auth/refresh:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 刷新 access token
|
||||
security: []
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
|
||||
/auth/logout:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 登出
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
|
||||
/auth/me:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: 当前用户信息
|
||||
responses:
|
||||
"200": { description: 成功, content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } }
|
||||
|
||||
/auth/2fa/status:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: 查询 2FA 状态
|
||||
responses:
|
||||
"200":
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ApiResponse"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
backup_codes_remaining: { type: integer }
|
||||
|
||||
/auth/2fa/enroll:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 开始 2FA 设置(返回密钥/二维码 URI/一次性备份码)
|
||||
responses:
|
||||
"200":
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ApiResponse"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
secret: { type: string }
|
||||
otpauth_uri: { type: string }
|
||||
backup_codes: { type: array, items: { type: string } }
|
||||
"409": { description: 已启用(40902) }
|
||||
|
||||
/auth/2fa/verify:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 校验验证码并启用 2FA
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code]
|
||||
properties:
|
||||
code: { type: string }
|
||||
responses:
|
||||
"200": { description: 已启用 }
|
||||
"401": { description: 验证码错误(40111) }
|
||||
|
||||
/auth/2fa/disable:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 关闭 2FA(需 TOTP 或备份码)
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
backup_code: { type: string }
|
||||
responses:
|
||||
"200": { description: 已关闭 }
|
||||
"401": { description: 验证码或备份码错误(40111) }
|
||||
|
||||
/organizations:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: 机构列表(公开)
|
||||
security: []
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
|
||||
/store/categories:
|
||||
get:
|
||||
tags: [store]
|
||||
summary: 应用分类(缓存 60s)
|
||||
security: []
|
||||
parameters:
|
||||
- { name: org_id, in: query, schema: { type: string } }
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
|
||||
/store/apps:
|
||||
get:
|
||||
tags: [store]
|
||||
summary: 应用列表(分页/搜索/排序)
|
||||
security: []
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, default: 1 } }
|
||||
- { name: page_size, in: query, schema: { type: integer, default: 20, maximum: 50 } }
|
||||
- { name: q, in: query, schema: { type: string } }
|
||||
- { name: category, in: query, schema: { type: string } }
|
||||
- { name: sort, in: query, schema: { type: string, enum: [popular, rating, latest] } }
|
||||
- { name: org_id, in: query, schema: { type: string } }
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
|
||||
/store/apps/{slug}:
|
||||
get:
|
||||
tags: [store]
|
||||
summary: 应用详情
|
||||
security: []
|
||||
parameters:
|
||||
- { name: slug, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
"404": { description: 不存在 }
|
||||
|
||||
/store/featured:
|
||||
get:
|
||||
tags: [store]
|
||||
summary: 精选应用(缓存 60s)
|
||||
security: []
|
||||
parameters:
|
||||
- { name: org_id, in: query, schema: { type: string } }
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
|
||||
/store/rankings:
|
||||
get:
|
||||
tags: [store]
|
||||
summary: 应用排行榜(缓存 60s)
|
||||
security: []
|
||||
parameters:
|
||||
- { name: org_id, in: query, schema: { type: string } }
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
|
||||
/apps/{id}/chat:
|
||||
post:
|
||||
tags: [apps]
|
||||
summary: 对话(SSE 流式)
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [message]
|
||||
properties:
|
||||
message: { type: string }
|
||||
conversation_id: { type: string }
|
||||
responses:
|
||||
"200": { description: SSE 事件流(text/event-stream) }
|
||||
|
||||
/apps/{id}/completion:
|
||||
post:
|
||||
tags: [apps]
|
||||
summary: 补全(SSE 流式)
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [message]
|
||||
properties:
|
||||
message: { type: string }
|
||||
responses:
|
||||
"200": { description: SSE 事件流 }
|
||||
|
||||
/research/tasks:
|
||||
post:
|
||||
tags: [research]
|
||||
summary: 创建深度研究任务
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ResearchCreateRequest" }
|
||||
responses:
|
||||
"201": { description: 已创建(返回 task_id, status=pending) }
|
||||
"400": { description: 题目为空 }
|
||||
get:
|
||||
tags: [research]
|
||||
summary: 我的研究任务列表
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
|
||||
/research/tasks/{taskId}:
|
||||
get:
|
||||
tags: [research]
|
||||
summary: 查询研究任务状态/结果
|
||||
parameters:
|
||||
- { name: taskId, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
"200":
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ApiResponse"
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/ResearchTask" }
|
||||
"404": { description: 不存在 }
|
||||
|
||||
/research/tasks/{taskId}/cancel:
|
||||
post:
|
||||
tags: [research]
|
||||
summary: 取消进行中的研究任务
|
||||
parameters:
|
||||
- { name: taskId, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
"200": { description: 已取消 }
|
||||
"404": { description: 不存在或无法取消 }
|
||||
|
||||
/ppt/tasks:
|
||||
post:
|
||||
tags: [ppt]
|
||||
summary: 创建 PPT 生成任务
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [title, source_content]
|
||||
properties:
|
||||
title: { type: string }
|
||||
source_type: { type: string, enum: [text, url] }
|
||||
source_content: { type: string }
|
||||
config: { type: object }
|
||||
responses:
|
||||
"201": { description: 已创建 }
|
||||
|
||||
/ppt/tasks/{taskId}:
|
||||
get:
|
||||
tags: [ppt]
|
||||
summary: 查询 PPT 任务状态
|
||||
parameters:
|
||||
- { name: taskId, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
"404": { description: 不存在 }
|
||||
|
||||
/knowledge:
|
||||
get:
|
||||
tags: [knowledge]
|
||||
summary: 知识库列表
|
||||
responses:
|
||||
"200": { description: 成功 }
|
||||
post:
|
||||
tags: [knowledge]
|
||||
summary: 创建知识库
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
responses:
|
||||
"201": { description: 已创建 }
|
||||
|
||||
/health:
|
||||
get:
|
||||
tags: [ops]
|
||||
summary: 健康检查
|
||||
security: []
|
||||
responses:
|
||||
"200": { description: ok }
|
||||
|
||||
/metrics:
|
||||
get:
|
||||
tags: [ops]
|
||||
summary: Prometheus 指标(文本格式,供监控抓取)
|
||||
description: 注意此端点位于 `/metrics`(非 `/api/v1` 前缀下)。
|
||||
security: []
|
||||
responses:
|
||||
"200": { description: Prometheus 文本格式指标 }
|
||||
Reference in New Issue
Block a user