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:
eaiadmin
2026-09-19 01:23:51 +08:00
co-authored by Claude Code
parent ddd2d2cbd8
commit 14f303459e
448 changed files with 18792 additions and 17027 deletions
@@ -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))
}