refactor: 后端仓库层收口(A1:课程/产品/素材)+ 收进工作区既有对象化重构
本提交含两部分。第一部分是本轮工作;第二部分是此前一直留在工作区、
从未提交的对象化重构,与第一部分在文件上互相咬合(internal/repository
整个包都是未跟踪状态,且 api 层已有文件引用它),无法拆成两个可编译的提交。
一、仓库层收口 A1 批(本轮工作)
把 api 层手写的 store.DB 查询收进具名仓库方法,只给真正获益的对象做方法,
不机械包裹全量。本批迁移 22 处裸查询(courses.go 9 / media.go 12 / products.go 1),
新增方法:
- MediaFileRepo.ListByBind / ListForAudit / MarkExtracted
- KnowledgeChunkRepo.CountByMediaFile
- ProductRepo.GetVisibleByID
两条业务口径改由仓库单点持有,避免各处手写漂移:
「只有 approved 素材出现在课程详情」与「已停用产品不在课程详情露出」。
修掉两个真实缺陷:
- ProductRepo.GetByID 缺 Where 条件。此前 GET /api/products/{id} 对任意 id 都返回
第一条产品、对不存在的 id 返回 200,且 PUT /api/products/{id} 会覆盖第一条产品
—— 数据损坏级。全仓扫描确认这是唯一一处同型写法。
- ProductRepo.Delete 写 status="deleted",而 DELETE 处理器文档与回包都声称
"inactive",接口在说谎;管理员用 status=all 拉列表会看到前端不认识的状态。
已对齐为 inactive(与 CourseRepo.Delete 一致)。
删除 8 个零调用且列名不存在的死方法(一调即 SQL 报错):
- media_file 上的 file_path / file_type / approval_status 三列并不存在,
GetByPath / ListByType / UpdateStatus 全废
- knowledge_chunk 上的 space_id 列不存在(模型早已改为 knowledge_space_key),
List / Total / ListBySpaceIDs / DeleteBySpace / SearchByVector 全废
取舍边界:能对当前 schema 跑通的死方法保留,跑不通的删或修。
CourseRepo.List 补齐 status=all 档(此前传给它会当作 status='all' 过滤出空列表)。
该方法此前零调用,现与产品列表语义对齐。
验证:go build ./... 与 go test ./... 全绿;另用真实 HTTP 请求验证 34 项
(课程 17 / 产品 3 / 素材 14),跑在数据库副本与独立 KB_DATA_DIR 上,
含 multipart 真上传 → 审批 → pdftotext 提取 → 分片入库的完整链路。
二、此前未提交的对象化重构(非本轮工作)
- 新增 internal/repository 仓库层、connectors、skills、specialists、xapps、jsonutil,
model/task_record|task_run|task_artifact、api/task_runtime|action_definition|chat_message
- 删除 api/app_definition、connectors、my_app_center、notification、office_skill、
export_docx|pptx|xlsx、official_account_* 等,随 XApp/Skill/Specialist/Connector
可插拔打包方向(AR10/AR11)调整
- 资产目录归位:backend-go/knowledge_source → assets/knowledge/source、
training_materials → assets/training/materials;README 内相对路径同步加深两级;
deploy env 补 ASSET_ROOT_DIR 并改 KNOWLEDGE_SOURCE_DIR / TRAINING_MATERIALS_DIR
- 前端新增 skills/ specialists/ connectors/ xapps/ 目录与对应页面
验证:前端 npm run build 通过(7.26s)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -18,20 +18,27 @@ import (
|
||||
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/repository"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// mediaRepo 素材仓库(便于测试时覆写),包内共享。
|
||||
var mediaRepo repository.MediaFileRepo
|
||||
|
||||
func init() {
|
||||
mediaRepo = repository.MediaFileRepo{}
|
||||
}
|
||||
|
||||
var blockedUploadExt = map[string]bool{
|
||||
"exe": true, "bin": true, "dll": true, "so": true, "dylib": true,
|
||||
"msi": true, "apk": true, "ipa": true, "deb": true, "rpm": true, "pkg": true, "appimage": true,
|
||||
"bat": true, "cmd": true, "com": true, "scr": true, "sys": true, "drv": true,
|
||||
"ps1": true, "psm1": true, "vbs": true, "vbe": true, "js": true, "jse": true, "wsf": true, "wsh": true,
|
||||
"reg": true, "lnk": true, "iso": true, "img": true, "dmg": true,
|
||||
"exe": true, "bin": true, "dll": true, "so": true, "dylib": true,
|
||||
"msi": true, "apk": true, "ipa": true, "deb": true, "rpm": true, "pkg": true, "appimage": true,
|
||||
"bat": true, "cmd": true, "com": true, "scr": true, "sys": true, "drv": true,
|
||||
"ps1": true, "psm1": true, "vbs": true, "vbe": true, "js": true, "jse": true, "wsf": true, "wsh": true,
|
||||
"reg": true, "lnk": true, "iso": true, "img": true, "dmg": true,
|
||||
}
|
||||
|
||||
var videoExt = map[string]bool{
|
||||
"mp4": true, "mov": true, "avi": true, "mkv": true, "webm": true, "m4v": true, "wmv": true, "flv": true,
|
||||
"mp4": true, "mov": true, "avi": true, "mkv": true, "webm": true, "m4v": true, "wmv": true, "flv": true,
|
||||
}
|
||||
|
||||
var validBindType = map[string]bool{"company": true, "product": true, "course": true, "none": true}
|
||||
@@ -52,19 +59,19 @@ func extOf(filename string) string {
|
||||
}
|
||||
|
||||
func isUploadableExt(ext string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(ext))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
return !blockedUploadExt[normalized]
|
||||
normalized := strings.ToLower(strings.TrimSpace(ext))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
return !blockedUploadExt[normalized]
|
||||
}
|
||||
|
||||
func isVideoExt(ext string) bool {
|
||||
return videoExt[strings.ToLower(strings.TrimSpace(ext))]
|
||||
return videoExt[strings.ToLower(strings.TrimSpace(ext))]
|
||||
}
|
||||
|
||||
func sizeLimitFor(ext string) int64 {
|
||||
if isVideoExt(ext) {
|
||||
if isVideoExt(ext) {
|
||||
return Cfg.FileMaxVideo
|
||||
}
|
||||
return Cfg.FileMaxDoc
|
||||
@@ -135,11 +142,11 @@ func Upload(c *gin.Context) {
|
||||
defer file.Close()
|
||||
|
||||
ext := extOf(header.Filename)
|
||||
if !isUploadableExt(ext) {
|
||||
if !isUploadableExt(ext) {
|
||||
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
|
||||
return
|
||||
}
|
||||
if isVideoExt(ext) && header.Size > Cfg.ChunkThreshold {
|
||||
if isVideoExt(ext) && header.Size > Cfg.ChunkThreshold {
|
||||
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
|
||||
return
|
||||
}
|
||||
@@ -189,7 +196,7 @@ func Upload(c *gin.Context) {
|
||||
BindID: bindID,
|
||||
KnowledgeSpaceKey: knowledgeSpaceKey,
|
||||
}
|
||||
if err := store.DB.Create(&m).Error; err != nil {
|
||||
if !mediaRepo.Insert(&m) {
|
||||
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
|
||||
return
|
||||
}
|
||||
@@ -232,7 +239,7 @@ func UploadInit(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ext := extOf(req.Filename)
|
||||
if !isUploadableExt(ext) {
|
||||
if !isUploadableExt(ext) {
|
||||
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
|
||||
return
|
||||
}
|
||||
@@ -380,7 +387,7 @@ func UploadComplete(c *gin.Context) {
|
||||
Source: source, SubmitterID: u.ID, BindType: sess.BindType, BindID: sess.BindID,
|
||||
KnowledgeSpaceKey: sess.KnowledgeSpaceKey,
|
||||
}
|
||||
if err := store.DB.Create(&m).Error; err != nil {
|
||||
if !mediaRepo.Insert(&m) {
|
||||
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
|
||||
return
|
||||
}
|
||||
@@ -392,14 +399,14 @@ func UploadComplete(c *gin.Context) {
|
||||
|
||||
// ============ 预览 / 状态 ============
|
||||
|
||||
// Preview GET /api/media/preview/{mediaId} —— 仅 approved 可预览
|
||||
// Preview GET /api/media/preview/{media_id} —— 仅 approved 可预览
|
||||
func Preview(c *gin.Context) {
|
||||
id, ok := parseID(c, "mediaId")
|
||||
id, ok := parseID(c, "media_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var m model.MediaFile
|
||||
if err := store.DB.First(&m, id).Error; err != nil {
|
||||
m, found := mediaRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("素材不存在"))
|
||||
return
|
||||
}
|
||||
@@ -414,33 +421,29 @@ func Preview(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// MediaStatus GET /api/media/status/{mediaId}
|
||||
// MediaStatus GET /api/media/status/{media_id}
|
||||
func MediaStatus(c *gin.Context) {
|
||||
id, ok := parseID(c, "mediaId")
|
||||
id, ok := parseID(c, "media_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var m model.MediaFile
|
||||
if err := store.DB.First(&m, id).Error; err != nil {
|
||||
m, found := mediaRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("素材不存在"))
|
||||
return
|
||||
}
|
||||
var chunkCount int64
|
||||
store.DB.Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", m.ID).Count(&chunkCount)
|
||||
web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount, "knowledge_space_key": m.KnowledgeSpaceKey})
|
||||
web.OK(c, gin.H{
|
||||
"status": m.Status,
|
||||
"extracted": m.Extracted,
|
||||
"chunk_count": chunkRepo.CountByMediaFile(m.ID),
|
||||
"knowledge_space_key": m.KnowledgeSpaceKey,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 审批 ============
|
||||
|
||||
// AuditList GET /api/media/audit-list?status=&page=&size=
|
||||
func AuditList(c *gin.Context) {
|
||||
q := store.DB.Model(&model.MediaFile{})
|
||||
if s := c.Query("status"); s != "" {
|
||||
q = q.Where("status = ?", s)
|
||||
}
|
||||
if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" {
|
||||
q = q.Where("knowledge_space_key = ?", key)
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
if page < 1 {
|
||||
@@ -449,22 +452,20 @@ func AuditList(c *gin.Context) {
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var items []model.MediaFile
|
||||
q.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items)
|
||||
total, items := mediaRepo.ListForAudit(
|
||||
c.Query("status"), sanitizeSpaceKey(c.Query("knowledge_space_key")), page, size)
|
||||
web.OK(c, gin.H{"total": total, "items": items})
|
||||
}
|
||||
|
||||
// AuditMedia POST /api/media/audit/{mediaId} —— 审批(通过→触发异步提取)
|
||||
// AuditMedia POST /api/media/audit/{media_id} —— 审批(通过→触发异步提取)
|
||||
func AuditMedia(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
id, ok := parseID(c, "mediaId")
|
||||
id, ok := parseID(c, "media_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var m model.MediaFile
|
||||
if err := store.DB.First(&m, id).Error; err != nil {
|
||||
m, found := mediaRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("素材不存在"))
|
||||
return
|
||||
}
|
||||
@@ -523,7 +524,7 @@ func AuditMedia(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Save(&m).Error; err != nil {
|
||||
if !mediaRepo.Update(&m) {
|
||||
web.Fail(c, web.NewBadRequest("审批失败"))
|
||||
return
|
||||
}
|
||||
@@ -537,8 +538,8 @@ func AuditMedia(c *gin.Context) {
|
||||
|
||||
// runExtractPipeline 审批通过后:文档转 PDF → pdftotext → 切片入库
|
||||
func runExtractPipeline(mediaID uint) {
|
||||
var m model.MediaFile
|
||||
if err := store.DB.First(&m, mediaID).Error; err != nil {
|
||||
m, found := mediaRepo.GetByID(mediaID)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
docExts := map[string]bool{"ppt": true, "pptx": true, "doc": true, "docx": true}
|
||||
@@ -553,7 +554,7 @@ func runExtractPipeline(mediaID uint) {
|
||||
text, err = pdftotextExtract(src)
|
||||
default:
|
||||
// 视频/图片:仅标记,不提取
|
||||
store.DB.Model(&m).Update("extracted", true)
|
||||
mediaRepo.MarkExtracted(m.ID)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -562,20 +563,24 @@ func runExtractPipeline(mediaID uint) {
|
||||
}
|
||||
|
||||
chunks := splitIntoChunks(text, 1000)
|
||||
spaceKey := resolveMediaKnowledgeSpaceKey(m)
|
||||
sourceID := strconv.FormatUint(uint64(m.ID), 10)
|
||||
items := make([]model.KnowledgeChunk, 0, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
if strings.TrimSpace(chunk) == "" {
|
||||
continue
|
||||
}
|
||||
store.DB.Create(&model.KnowledgeChunk{
|
||||
items = append(items, model.KnowledgeChunk{
|
||||
MediaFileID: &m.ID,
|
||||
SourceType: m.FileExt,
|
||||
SourceID: strconv.FormatUint(uint64(m.ID), 10),
|
||||
KnowledgeSpaceKey: resolveMediaKnowledgeSpaceKey(m),
|
||||
SourceID: sourceID,
|
||||
KnowledgeSpaceKey: spaceKey,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
})
|
||||
}
|
||||
store.DB.Model(&m).Update("extracted", true)
|
||||
chunkRepo.BulkInsert(items)
|
||||
mediaRepo.MarkExtracted(m.ID)
|
||||
triggerKnowledgeIndexRebuild()
|
||||
log.Printf("[提取完成] media_id=%d chunks=%d", mediaID, len(chunks))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user