refactor: 后端仓库层收口(A5:技能/应用定义 + 用户应用中心)

D25 定的对象层三类一级对象(专员 / 技能 / 应用),A4 收了专员,这次是
技能与应用。原先是 handler 里直接 store.DB.Create/Save/Delete,现在统一走
repository,与 A1~A4 同一形状:仓库方法返回 bool,三态参数用 *bool 表达。

新增:
- repository.SkillDefinitionRepo / XAppDefinitionRepo —— List / GetByID /
  GetByKey / Insert / Update / Delete
- repository.UserXAppCenterRepo —— 收藏/最近使用/自定义应用,每人一份

两处刻意偏离既有惯例,都写了理由在代码里:

1. List(state string, exposedToUser *bool) 不在仓库里定默认值。「不传 state
   就只看 active」是列表接口的契约,解析 query 参数是 handler 的活;仓库只
   执行过滤。exposedToUser 用 *bool 而非 bool,因为「不传该参数」与「传
   false」语义不同——前者要全量,压成 bool 会把不传当成 false。

2. UserXAppCenterRepo.FindByUser 返回 (row, error) 而不是 bool。「没找到」在
   这个仓库是有意义的第三态:表示该用户还没配过、调用方要新建一份。若压成
   bool,一旦读取真出错(SQLite 本地锁等待是常事)就会被当成「没配过」,转而
   写一份空白配置,把用户已有的收藏和最近使用抹掉。

技能与应用的读接口共用同一个仓库变量,只声明一处(skillDefinitionRepo 留在
admin_handlers.go)——两份变量持有同一仓库时,测试里覆写一份、另一份照旧,
行为会静默分叉。

A5 范围内的 store.DB 直用已清零;skills/api/office_handlers.go 里仍有 3 处,
是 store.DB.Transaction,QueryBuilder 不带事务,留着别动。

验证(tmp_vfy_a5,临时程序,验完已删):真实路由 + 真实 HTTP,跑在数据库
副本上。55 条断言全绿,覆盖技能/应用的列表三态、按 key 取、增改删、404 路径、
用户应用中心的空配置/覆盖写/多用户隔离。

另有 14 条变异测试确认断言真的会红(13 捕获 / 1 设计上不可观测),其中
排序断言原本是假的:三条种子的插入顺序恰好也是 sort_order 升序,删掉
ORDER BY 照样绿。补了一条「sort_order 更小但插入更晚」的记录才透光。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-19 02:11:00 +08:00
co-authored by Claude Code
parent 71f1b6246f
commit c5af5084b1
6 changed files with 235 additions and 66 deletions
@@ -2,22 +2,31 @@ package xappapi
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"eai_agentplatform/backend/internal/jsonutil"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/repository"
"eai_agentplatform/backend/internal/web"
xappdefs "eai_agentplatform/backend/internal/xapps/model"
)
// 应用目录与用户应用中心两个仓库(便于测试时覆写),本包共用。
var (
xAppDefinitionRepo repository.XAppDefinitionRepo
userXAppCenterRepo repository.UserXAppCenterRepo
)
func init() {
xAppDefinitionRepo = repository.XAppDefinitionRepo{}
userXAppCenterRepo = repository.UserXAppCenterRepo{}
}
type definitionReq struct {
Key string `json:"key"`
Label string `json:"label"`
@@ -114,21 +123,19 @@ func validateDefinitionReq(req *definitionReq) *web.AppError {
}
func ListXAppDefinitions(c *gin.Context) {
q := store.DB.Model(&xappdefs.XAppDefinition{})
if c.Query("state") == "" {
q = q.Where("state = ?", "active")
} else {
q = q.Where("state = ?", c.Query("state"))
// 不传 state 默认只看 active —— 这是列表接口的契约,放在这里解析。
state := c.Query("state")
if state == "" {
state = "active"
}
if c.Query("exposed_to_user") != "" {
q = q.Where("exposed_to_user = ?", c.Query("exposed_to_user") == "true")
// exposed_to_user 三态:不传 = 不过滤,传了按 true/false 精确匹配
// (原实现是 `!= "true"` 一律当 false,这里保持同一口径)。
var exposed *bool
if v := c.Query("exposed_to_user"); v != "" {
b := v == "true"
exposed = &b
}
var items []xappdefs.XAppDefinition
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询应用定义失败"))
return
}
web.OK(c, items)
web.OK(c, xAppDefinitionRepo.List(state, exposed))
}
func GetXAppDefinitionByKey(c *gin.Context) {
@@ -137,8 +144,8 @@ func GetXAppDefinitionByKey(c *gin.Context) {
web.Fail(c, web.NewBadRequest("应用 key 不能为空"))
return
}
var item xappdefs.XAppDefinition
if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil {
item, found := xAppDefinitionRepo.GetByKey(key)
if !found {
web.Fail(c, web.NewNotFoundError("应用定义不存在"))
return
}
@@ -180,7 +187,7 @@ func CreateXAppDefinition(c *gin.Context) {
State: req.State,
SortOrder: req.SortOrder,
}
if err := store.DB.Create(&item).Error; err != nil {
if !xAppDefinitionRepo.Insert(&item) {
web.Fail(c, web.NewBadRequest("创建应用定义失败"))
return
}
@@ -192,8 +199,8 @@ func UpdateXAppDefinition(c *gin.Context) {
if !ok {
return
}
var item xappdefs.XAppDefinition
if err := store.DB.First(&item, id).Error; err != nil {
item, found := xAppDefinitionRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("应用定义不存在"))
return
}
@@ -229,7 +236,7 @@ func UpdateXAppDefinition(c *gin.Context) {
item.ExposedToUser = req.ExposedToUser
item.State = req.State
item.SortOrder = req.SortOrder
if err := store.DB.Save(&item).Error; err != nil {
if !xAppDefinitionRepo.Update(&item) {
web.Fail(c, web.NewBadRequest("更新应用定义失败"))
return
}
@@ -241,12 +248,12 @@ func DeleteXAppDefinition(c *gin.Context) {
if !ok {
return
}
var item xappdefs.XAppDefinition
if err := store.DB.First(&item, id).Error; err != nil {
item, found := xAppDefinitionRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("应用定义不存在"))
return
}
if err := store.DB.Delete(&item).Error; err != nil {
if !xAppDefinitionRepo.Delete(&item) {
web.Fail(c, web.NewBadRequest("删除应用定义失败"))
return
}
@@ -290,7 +297,7 @@ func GetMyXAppCenter(c *gin.Context) {
web.Fail(c, web.NewAuthError("未登录"))
return
}
row, err := findUserXAppCenter(user.ID)
row, err := userXAppCenterRepo.FindByUser(user.ID)
if err != nil {
web.Fail(c, web.NewBadRequest("读取应用中心失败"))
return
@@ -314,7 +321,7 @@ func UpdateMyXAppCenter(c *gin.Context) {
RecentKeys: mustJSONXAppCenter(req.RecentKeys),
CustomXApps: mustJSONXAppCenter(req.CustomXApps),
})
row, err := findUserXAppCenter(user.ID)
row, err := userXAppCenterRepo.FindByUser(user.ID)
if err != nil {
web.Fail(c, web.NewBadRequest("读取应用中心失败"))
return
@@ -325,25 +332,13 @@ func UpdateMyXAppCenter(c *gin.Context) {
row.FavoriteKeys = mustJSONXAppCenter(payload.FavoriteKeys)
row.RecentKeys = mustJSONXAppCenter(payload.RecentKeys)
row.CustomXApps = mustJSONXAppCenter(payload.CustomXApps)
if err := store.DB.Save(row).Error; err != nil {
if !userXAppCenterRepo.Save(row) {
web.Fail(c, web.NewBadRequest("保存应用中心失败"))
return
}
web.OK(c, payload)
}
func findUserXAppCenter(userID uint) (*xappdefs.UserXAppCenter, error) {
var row xappdefs.UserXAppCenter
err := store.DB.Where("user_id = ?", userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func normalizeXAppCenterPayload(row *xappdefs.UserXAppCenter) centerPayload {
if row == nil {
return centerPayload{