Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/api/project.go
T
eaiadminandClaude Code 14f303459e 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>
2026-09-19 01:23:51 +08:00

272 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"errors"
"io"
"strings"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
// 项目名的长度上限。前端弹窗的计数器读的也是这个数 —— 两边必须一致,
// 否则前端数到 40 说没超,后端一剪,用户看到的名字和自己打的不一样。
const projectNameMaxLen = 40
type projectReq struct {
Name string `json:"name"`
TemplateKey string `json:"template_key"`
// Instruction 用指针:值类型分不出「没传」和「传了空串」,指令就永远清不掉 ——
// 详情页把指令删空再保存会被当成「没改」,用户看到的是白删一次。
// Pinned 同理(分不出「没传」和「传了 false」,取消置顶永远取消不掉)。
Instruction *string `json:"instruction"`
SpecialistKeys []string `json:"specialist_keys"`
SkillKeys []string `json:"skill_keys"`
ConnectorKeys []string `json:"connector_keys"`
Pinned *bool `json:"pinned"`
}
// encodeKeys 把 key 列表存成 JSON 字符串。空列表存空串而不是 "[]",
// 读的时候一眼能看出「没配」和「配了但为空」的区别不大,但空串更省。
func encodeKeys(keys []string) string {
cleaned := make([]string, 0, len(keys))
for _, key := range keys {
if trimmed := strings.TrimSpace(key); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
if len(cleaned) == 0 {
return ""
}
b, err := json.Marshal(cleaned)
if err != nil {
return ""
}
return string(b)
}
// ListProjects 当前用户的项目,置顶在前、最近动过的靠前。
func ListProjects(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var items []model.Project
if err := specialistruntime.ProjectQuery(user).
Order("pinned DESC, updated_at DESC, id DESC").
Limit(50).
Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询项目失败"))
return
}
web.OK(c, items)
}
// CreateProject 建一个项目。空 body 也收(跟「新建任务」一样,全走默认值),
// 但名字是必填的 —— 一个没名字的项目在列表里没法认。
func CreateProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req projectReq
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
web.Fail(c, web.NewBadRequest("项目名称不能为空"))
return
}
if len([]rune(name)) > projectNameMaxLen {
name = string([]rune(name)[:projectNameMaxLen])
}
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
web.Fail(c, web.NewNotFoundError(err.Error()))
return
}
instruction := ""
if req.Instruction != nil {
instruction = strings.TrimSpace(*req.Instruction)
}
project := model.Project{
Name: name,
Instruction: instruction,
TemplateKey: strings.TrimSpace(req.TemplateKey),
// 归属由服务端定死,不读请求里的 owner —— 否则谁都能替别人建项目。
Owner: myTaskOwnerName(user),
SpecialistKeys: encodeKeys(req.SpecialistKeys),
SkillKeys: encodeKeys(req.SkillKeys),
ConnectorKeys: encodeKeys(req.ConnectorKeys),
}
if err := store.DB.Create(&project).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建项目失败"))
return
}
web.OK(c, project)
}
// UpdateProject 改名 / 改指令 / 置顶。只有显式传了的字段才覆盖:
// 没传 name 就别把名字清空,没传 pinned 就别把它当 false。
func UpdateProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var project model.Project
if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
var req projectReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if name := strings.TrimSpace(req.Name); name != "" {
if len([]rune(name)) > projectNameMaxLen {
name = string([]rune(name)[:projectNameMaxLen])
}
project.Name = name
}
// 传了空串就是「清空指令」,得让它生效 —— 见 projectReq 里 Instruction 的注释。
if req.Instruction != nil {
project.Instruction = strings.TrimSpace(*req.Instruction)
}
if req.TemplateKey != "" {
project.TemplateKey = strings.TrimSpace(req.TemplateKey)
}
if req.Pinned != nil {
project.Pinned = *req.Pinned
}
// 三个对象列表:给了就用给的(空数组也算给了,表示清空),
// 没给就保持原样。用 nil 判断,跟 Pinned 是同一套规矩。
if req.SpecialistKeys != nil {
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
web.Fail(c, web.NewNotFoundError(err.Error()))
return
}
project.SpecialistKeys = encodeKeys(req.SpecialistKeys)
}
if req.SkillKeys != nil {
project.SkillKeys = encodeKeys(req.SkillKeys)
}
if req.ConnectorKeys != nil {
project.ConnectorKeys = encodeKeys(req.ConnectorKeys)
}
if err := store.DB.Save(&project).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新项目失败"))
return
}
web.OK(c, project)
}
// DeleteProject 删项目。
//
// 项目里的任务**不删**,只把 project_id 置空 —— 任务是「做过的事」,
// 删一个分组不该把它一起抹掉。前端确认框里必须把这一点说明白,
// 否则用户会以为连任务一起没了。
func DeleteProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var project model.Project
if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
if err := store.DB.Model(&model.TaskRecord{}).
Where("project_id = ?", project.ID).
Update("project_id", nil).Error; err != nil {
web.Fail(c, web.NewBadRequest("解除任务归属失败"))
return
}
if err := store.DB.Delete(&project).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除项目失败"))
return
}
web.OK(c, gin.H{"id": id})
}
// ListProjectTasks 项目下的任务。项目必须是自己的 —— 别人的项目直接 404,
// 不区分「不存在」和「不是你的」。
func ListProjectTasks(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var project model.Project
if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
var items []model.TaskRecord
if err := store.DB.Where("project_id = ?", project.ID).
Order("updated_at DESC, id DESC").
Limit(100).
Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询项目任务失败"))
return
}
web.OK(c, items)
}
// validateSpecialistKeys 专员 key 得真实存在才让存 —— 项目卡片上要显示专员名,
// 存一个查不到的 key 进去,卡片上就会出现一行认不出来的东西。
// 工具和连接器暂时不校验:工具是前端路由表里的常量,连接器清单以后会变,
// 校验它们只会让老项目在清单变动后改不动。
func validateSpecialistKeys(keys []string) error {
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
var specialist specialistmodel.Specialist
if err := store.DB.Where("key = ?", trimmed).First(&specialist).Error; err != nil {
return errors.New("专员不存在:" + trimmed)
}
}
return nil
}