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:
@@ -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/`
|
||||
Reference in New Issue
Block a user