Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/api/project.go
T
eaiadminandClaude Code 4f31e0c438 refactor: 后端仓库层收口(A4:任务/项目/笔记/专员/动作定义)
把 B 档(此前尚无仓库的对象)的 api 裸查询收进仓库,api 层裸 store.DB
从 162 降到 96(口径:internal/ 下非测试 .go,不含 internal/repository/ 自身)。
新增 7 个仓库:TaskRecordRepo / TaskArtifactRepo / TaskRunRepo / ProjectRepo /
StudyNoteRepo / SpecialistRepo / ActionDefinitionRepo。

按对象补齐的方法:
- TaskRecordRepo:GetByID / GetByIDForOwners / ListBySpecialistKey / ListByOwners /
  ListByProject / CountBySpecialistKey / ClearProject / DeleteCascade
- ProjectRepo:GetByIDForOwners / ListByOwners
- StudyNoteRepo:ListByUser / GetByID
- SpecialistRepo:GetByKey / GetByID / Query / List / CountByKey
- TaskArtifactRepo / TaskRunRepo:GetByID / ListByTask
- ActionDefinitionRepo:List / GetByID / GetByKey

几条口径改由仓库单点持有,避免各处手写漂移:
- 归属过滤抽成 ownerScope:owners 为空时套恒假条件(空集),绝不退化成全表。
  specialists/runtime 的 MyTaskQuery / ProjectQuery 随之删除,调用方改用
  MyTaskOwners + 仓库方法 —— 任务与项目共用同一套归属口径。
- 「任务 + 交付物 + 运行记录三张表同事务级联删除」从 handler 收进 DeleteCascade,
  不留没有任务的孤儿交付物;删项目只解除其下任务的 project_id 归属(置 NULL,
  不是 0),不删任务本身 —— 任务是「做过的事」,删一个分组不该把它一起抹掉。
- 专员目录的 state 档位(默认 active / all 仅管理员 / system / 其它精确匹配,
  且除显式要 system 外一律排除 system 记录)收进 SpecialistRepo.List。
- GetByKey 有意不过滤 state:调用方口径不同(建任务时要能查到,对话取 prompt
  时要拒绝 inactive),口径留在调用方,仓库只负责取数。顺带把「按 key 找专员」
  从 4 个文件里各写一遍收敛成一处。
- ActionDefinitionRepo.List 不替调用方定 state 默认值 ——「不传 state 就只看
  active」是列表接口的契约,由 handler 解析 query 后传入。
- StudyNoteRepo.GetByID 不判归属,越权检查留在 handler(那里能把「不存在」与
  「不是你的」分别回成 404 / 403)。

保留未动:skills/api/office_handlers.go 里两处 store.DB.Transaction —— 运行记录
与交付物要在同一个事务里落库,QueryBuilder 不带事务,维持原样并就地注明。

验证:go build ./... 与 go vet ./... 干净,go test ./... 6 个包全绿。
另用独立验证程序走真实路由 + 真实 HTTP,对数据库副本跑 148 项断言全绿
(覆盖跨用户越权 404、空 owners 退化成空集、级联删除、解除归属置 NULL、
笔记按用户隔离、专员 key 唯一性排除自身、管理员 state=all 仍排除 system 等)。

另对其中 10 条关键语义做了变异测试:逐条注入反向实现,确认断言确实会失败,
捕获 10 / 漏掉 0。变异测试同时暴露并修掉了验证体系自身的两个漏洞:
- 「state=all 排除 system」这条规则此前没有任何断言能观察到 —— 默认档被
  state=active 挡着、system 档被 state=system 挡着,删掉实现也不会红;
- 变异驱动只跑 HTTP 断言、不跑 go test,导致针对单元测试注入的变异
  (拒绝已下线专员的 prompt)永远逮不到。

验证程序为一次性脚手架,验证完成后已删除(tmp_vfy_b/)。
原始 data/eai_agentplatform.db 全程未触碰(mtime 仍为 2026-09-17 15:14)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 01:55:26 +08:00

249 lines
7.4 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"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"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
}
web.OK(c, projectRepo.ListByOwners(specialistruntime.MyTaskOwners(user), 50))
}
// 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 !projectRepo.Insert(&project) {
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
}
project, found := projectRepo.GetByIDForOwners(id, specialistruntime.MyTaskOwners(user))
if !found {
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 !projectRepo.Update(&project) {
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
}
project, found := projectRepo.GetByIDForOwners(id, specialistruntime.MyTaskOwners(user))
if !found {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
if !taskRecordRepo.ClearProject(project.ID) {
web.Fail(c, web.NewBadRequest("解除任务归属失败"))
return
}
if !projectRepo.Delete(&project) {
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
}
project, found := projectRepo.GetByIDForOwners(id, specialistruntime.MyTaskOwners(user))
if !found {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
web.OK(c, taskRecordRepo.ListByProject(project.ID, 100))
}
// validateSpecialistKeys 专员 key 得真实存在才让存 —— 项目卡片上要显示专员名,
// 存一个查不到的 key 进去,卡片上就会出现一行认不出来的东西。
// 工具和连接器暂时不校验:工具是前端路由表里的常量,连接器清单以后会变,
// 校验它们只会让老项目在清单变动后改不动。
func validateSpecialistKeys(keys []string) error {
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
if _, found := specialistRepo.GetByKey(trimmed); !found {
return errors.New("专员不存在:" + trimmed)
}
}
return nil
}