chore: 工作台产品化进行中的改动

把工作区里其余在制品一并入库,主要是工作台产品化的推进:

  后端:新增 capability_definition / project / my_app_center / office_skill
        接口与 action_definition / skill_definition / project / user_app_center
        模型,config 加路由健康上报。
  前端:新增 frontend/src/skills(Office 技能与 workbuddy 复刻)、
        项目管理、应用中心、能力目录页,以及配套 api / store / config;
        聊天侧新增 SpecialistChip / SpecialistPanel / SkillStrip / AppChatRail
        等组件。
  清理:移除旧 views/tools 下的单页工具(已并入工作台)、_frozen 冻结组件、
        cmd/inspect_oa_debug 调试入口,以及两份调试笔记。
  其它:文档与启动脚本同步。

(这批改动与上一提交的 SY23 工作并行进行,此前已在同一工作区内交织。)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-17 21:32:35 +08:00
co-authored by Claude Code
parent 8b136d4a10
commit 16d63de4e1
180 changed files with 22283 additions and 13850 deletions
+278 -162
View File
@@ -1,6 +1,6 @@
# BE03 — 素材模块设计
> **版本:V1.1 | 技术:分片上传 + LibreOffice + PyMuPDF**
> **版本:V2.0 | 技术:Go + Gin + GORM + SQLite + systemd + LibreOffice + pdftotext**
---
@@ -12,6 +12,8 @@
- 文件预览
- 转换状态查询
---
## 2. 素材状态流转
```
@@ -22,212 +24,326 @@
│ │
│ └─→ 触发异步转换管线
│ │
│ ├─ 文档 → LibreOffice 转 PDF → PyMuPDF 提取
│ ├─ 文档 → LibreOffice 转 PDF → pdftotext 提取
│ │ 文本 → 切片写入 knowledge_chunk
│ └─ 视频/图片 → 仅标记预览可用
│
└─ reject → rejected(已驳回,前台不可见)
```
## 3. 上传 API
---
## 3. 数据模型
素材模块对应 `media_file` 表(`internal/model/media.go`):
```go
// internal/model/media.go
type MediaFile struct {
model.Base
UserID uint `gorm:"not null;index"`
Filename string `gorm:"size:256;not null"`
StoredPath string `gorm:"size:512;not null"`
FileExt string `gorm:"size:16;not null"` // ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg
FileSize int64 `gorm:"not null"`
BindType string `gorm:"size:32"` // company|product|course|none
BindID *uint `gorm:"index"`
Status string `gorm:"size:16;not null;default:pending;index"` // pending|approved|rejected
AuditBy *uint
AuditAt *time.Time
Extracted bool `gorm:"not null;default:false"`
RejectReason string `gorm:"size:512"`
CreatedAt time.Time
UpdatedAt time.Time
}
```
---
## 4. 上传 API
### 直传(文档 ≤ 200MB)
```python
POST /api/media/upload
Content-Type: multipart/form-data
```go
// backend-go/internal/api/media.go
func Upload(c *gin.Context) {
// POST /api/media/upload
// Content-Type: multipart/form-data
Parameters:
- file: 文件二进制
- bind_type: company | product | course | none
- bind_id: 绑定实体 ID(可选)
file, _ := c.FormFile("file")
bindType := c.PostForm("bind_type") // company | product | course | none
bindIDStr := c.PostForm("bind_id") // 可选
Response:
{
uid := middleware.GetUserID(c)
mediaID, err := mediaSvc.Upload(c, uid, file, bindType, bindIDStr)
if err != nil { c.JSON(500, web.FAIL); return }
c.JSON(200, web.OK(gin.M{
"media_id": mediaID,
"status": "pending",
"filename": file.Filename,
}))
}
```
Response (SQLite TEXT 列):
```json
{
"code": 0,
"data": {
"media_id": 1,
"status": "pending",
"filename": "原始名称.pptx"
}
}
```
### 分片上传(视频 > 100MB)
```python
# 1. 初始化
POST /api/media/upload-init
{
"filename": "training.mp4",
"file_size": 524288000,
"bind_type": "course",
"bind_id": 1
}
Response: { "upload_id": "uuid", "chunk_size": 5242880, "chunk_count": 100 }
```go
// POST /api/media/upload-init
// → { "upload_id": "uuid", "chunk_size": 5242880, "chunk_count": 100 }
# 2. 上传分片(循环调用)
POST /api/media/upload-chunk
Content-Type: multipart/form-data
{
"upload_id": "uuid",
"chunk_index": 0,
"file": <binary>
}
// POST /api/media/upload-chunk (循环调用,Content-Type: multipart/form-data)
// → { "ok": true }
# 3. 完成合并
POST /api/media/upload-complete
{ "upload_id": "uuid" }
Response: { "media_id": 1, "status": "pending" }
// POST /api/media/upload-complete
// → { "media_id": 1, "status": "pending" }
```
## 4. 审批 API
### 前端分片上传流程(Axios + FormData)
```python
# 管理员
GET /api/media/audit-list?status=pending&page=1&size=20
```js
// 1. 初始化
const initResp = await axios.post('/api/media/upload-init', {
filename: 'training.mp4',
file_size: 524288000,
bind_type: 'course',
bind_id: 1,
})
const { upload_id, chunk_size, chunk_count } = initResp.data.data
POST /api/media/audit/{mediaId}
// 2. 逐片上传(并发或串行)
for (let i = 0; i < chunk_count; i++) {
const chunk = file.slice(i * chunk_size, (i + 1) * chunk_size)
await axios.post('/api/media/upload-chunk', chunk, {
params: { upload_id, chunk_index: i },
headers: { 'Content-Type': 'multipart/form-data' },
})
}
// 3. 完成合并
await axios.post('/api/media/upload-complete', null, {
params: { upload_id },
})
```
---
## 5. 审批 API
### 管理员端
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/media/audit-list?status=pending&page=1&size=20` | 审批列表 |
| POST | `/api/media/audit/{mediaId}` | 审批通过/驳回 |
POST 请求体:
```json
{
"action": "approve", # approve | reject
"reject_reason": "..." # 驳回时必填
"action": "approve", // approve | reject
"reject_reason": "..." // 驳回时必填
}
```
## 5. 异步转换管线
响应:
```python
# services/media_service.py
import threading
from datetime import datetime
def _async_convert_pipeline(media_id: int):
"""审批通过后的异步转换管线"""
try:
media = db.query(MediaFile).get(media_id)
# 仅文档需要转换(PPT/Word/PDF)
if media.file_ext in ("ppt", "pptx", "doc", "docx"):
# Step 1: LibreOffice 转 PDF
pdf_path = _libreoffice_to_pdf(media.stored_path)
# Step 2: PyMuPDF 提取文本
text = _pymupdf_extract(pdf_path)
# Step 3: 按段落切片入库
chunks = _split_into_chunks(text)
for i, chunk_text in enumerate(chunks):
db.add(KnowledgeChunk(
media_file_id=media.id,
source_type=media.file_ext,
chunk_index=i,
content=chunk_text,
))
elif media.file_ext == "pdf":
# PDF 直接 PyMuPDF 提取
text = _pymupdf_extract(media.stored_path)
chunks = _split_into_chunks(text)
for i, chunk_text in enumerate(chunks):
db.add(KnowledgeChunk(media_file_id=media.id, ...))
# 视频/图片:不提取文本
media.extracted = True
db.commit()
logger.info(f"转换完成: media_id={media_id}")
except Exception as e:
logger.error(f"转换失败: media_id={media_id}, error={e}")
media.extracted = False # 标记失败可重试
def approve_media(media_id: int, auditor_id: int):
"""审批通过 → 启动异步转换"""
media = db.query(MediaFile).get(media_id)
media.status = "approved"
media.audit_by = auditor_id
media.audit_at = datetime.utcnow()
db.commit()
thread = threading.Thread(target=_async_convert_pipeline, args=(media_id,))
thread.start()
```json
{ "code": 0, "data": { "media_id": 1, "status": "approved", "audit_by": 1 } }
```
## 6. LibreOffice 转换接口
### Go handler 实现
```python
# utils/libreoffice.py
import subprocess
import requests
```go
func AuditMedia(c *gin.Context) {
mediaID, _ := strconv.ParseUint(c.Param("mediaId"), 10, 32)
var req struct {
Action string `json:"action" binding:"required"` // approve | reject
RejectReason string `json:"reject_reason"`
}
c.ShouldBindJSON(&req)
def libreoffice_convert(input_path: str, output_dir: str) -> str:
"""调用 LibreOffice 容器将文档转 PDF"""
# 方式1:本地安装 libreoffice
subprocess.run([
"libreoffice", "--headless", "--convert-to", "pdf",
"--outdir", output_dir, input_path
], check=True)
# 方式2:Docker 容器 HTTP 接口
# response = requests.post(
# f"{settings.libreoffice_url}/convert",
# files={"file": open(input_path, "rb")}
# )
# return response.json()["pdf_path"]
uid := middleware.GetUserID(c)
err := mediaSvc.Audit(c, uint(mediaID), uid, req.Action, req.RejectReason)
if err != nil { c.JSON(500, web.FAIL); return }
c.JSON(200, web.OK(gin.M{"media_id": mediaID, "status": req.Action}))
}
```
## 7. PyMuPDF 文本提取
---
```python
# utils/pdf_extractor.py
import fitz # PyMuPDF
## 6. 异步转换管线
def extract_text(pdf_path: str) -> str:
"""提取 PDF 全部文本"""
doc = fitz.open(pdf_path)
text = ""
for page in doc:
text += page.get_text()
doc.close()
return text
审批通过后,后台 goroutine 执行转换管线:
def split_into_chunks(text: str, max_chars: int = 1000) -> list[str]:
"""按段落 + 最大字符数切片"""
paragraphs = text.split("\n\n")
chunks = []
current = ""
for p in paragraphs:
if len(current) + len(p) > max_chars:
if current:
chunks.append(current.strip())
```go
// backend-go/internal/api/media.go
func approveMedia(tx *gorm.DB, mediaID uint, auditorID uint) error {
media := &model.MediaFile{}
tx.Model(media).Where("id = ?", mediaID).First(media)
media.Status = "approved"
media.AuditBy = &auditorID
now := time.Now()
media.AuditAt = &now
tx.Save(media)
go func() { _asyncConvertPipeline(tx, mediaID) }()
return nil
}
func _asyncConvertPipeline(tx *gorm.DB, mediaID uint) {
media := &model.MediaFile{}
tx.Model(media).Where("id = ?", mediaID).First(media)
// 仅文档需要转换(PPT/Word/PDF)
switch media.FileExt {
case "ppt", "pptx", "doc", "docx":
// Step 1: LibreOffice 转 PDF
pdfPath := _libreofficeToPDF(media.StoredPath)
// Step 2: pdftotext 提取文本
text := _pdftotextExtract(pdfPath)
// Step 3: 按段落切片写入 knowledge_chunk
chunks := _splitIntoChunks(text)
for i, chunkText := range chunks {
tx.Create(&model.KnowledgeChunk{
MediaFileID: media.ID,
SourceType: media.FileExt,
ChunkIndex: i,
Content: chunkText,
})
}
case "pdf":
text := _pdftotextExtract(media.StoredPath)
chunks := _splitIntoChunks(text)
for i, chunkText := range chunks {
tx.Create(&model.KnowledgeChunk{
MediaFileID: media.ID,
SourceType: "pdf",
ChunkIndex: i,
Content: chunkText,
})
}
}
// 视频/图片:不提取文本
media.Extracted = true
tx.Save(media)
}
```
---
## 7. LibreOffice 转换接口(systemd 裸进程)
```go
func _libreofficeToPDF(inputPath string) string {
// 调用本地安装的 LibreOffice(systemd 部署,非 Docker)
outDir := filepath.Dir(inputPath)
cmd := exec.Command("libreoffice",
"--headless",
"--convert-to", "pdf",
"--outdir", outDir,
inputPath,
)
cmd.Run()
return strings.TrimSuffix(inputPath, filepath.Ext(inputPath)) + ".pdf"
}
```
---
## 8. pdftotext 文本提取
```go
func _pdftotextExtract(pdfPath string) string {
// 使用系统 pdftotext(poppler-utils)提取 PDF 文本
cmd := exec.Command("pdftotext", "-layout", pdfPath, "-")
out, _ := cmd.Output()
return string(out)
}
func _splitIntoChunks(text string, maxChars int) []string {
paragraphs := strings.Split(text, "\n\n")
var chunks []string
current := ""
for _, p := range paragraphs {
if len(current)+len(p) > maxChars {
if current != "" {
chunks = append(chunks, strings.TrimSpace(current))
}
current = p
else:
current += "\n\n" + p if current else p
if current:
chunks.append(current.strip())
} else {
if current != "" {
current += "\n\n" + p
} else {
current = p
}
}
}
if current != "" {
chunks = append(chunks, strings.TrimSpace(current))
}
return chunks
```
## 8. 预览 API
```python
GET /api/media/preview/{mediaId}
# 仅 approved 素材可预览
Response:
{
"preview_url": "/media/upload/uuid-filename.pdf",
"file_ext": "pdf",
"can_preview": true
}
GET /api/media/status/{mediaId}
# 查询素材状态(含提取进度)
Response:
{
"status": "approved",
"extracted": true,
"chunk_count": 42
}
```
## 9. 安全约束
---
- 扩展名白名单:ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg
## 9. 预览与状态 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/media/preview/{mediaId}` | 仅 approved 素材可预览 |
| GET | `/api/media/status/{mediaId}` | 查询素材状态(含提取进度) |
GET `/api/media/preview/{mediaId}` 响应:
```json
{
"code": 0,
"data": {
"preview_url": "/media/upload/uuid-filename.pdf",
"file_ext": "pdf",
"can_preview": true
}
}
```
GET `/api/media/status/{mediaId}` 响应:
```json
{
"code": 0,
"data": {
"status": "approved",
"extracted": true,
"chunk_count": 42
}
}
```
---
## 10. 安全约束
- 扩展名白名单:`ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg`
- 文件名重命名为 UUID,杜绝路径穿越
- 上传目录对静态预览只读,禁止直接执行
- 文件大小:文档 ≤ 200MB,视频 ≤ 2GB
- MIME 类型校验 + 扩展名双重校验
- MIME 类型校验 + 扩展名双重校验
- SQLite 单文件,数据目录 `/opt/eai_agentplatform/data/media/`