refactor: 后端仓库层更名为数据访问层(internal/repository → internal/dal)
「仓库层」是 repository 的直译,中文里与「代码仓库 / git 仓库」同词, 而这一层做的事就是数据访问。名字改成它实际在做的事。 改名口径(纯机械替换,无逻辑改动): - 包:internal/repository → internal/dal(package repository → package dal) - 类型:XxxRepo → XxxDAO(TaskRecordDAO / SpecialistDAO / PositionDAO …) - 变量:xxxRepo → xxxDAO - import 路径、包限定符、日志前缀 [repository] → [dal] 同步 - 注释里的「仓库层」→「数据访问层」;core.go 包注释补上 DAL/DAO 全称 命名规范补登(AR09 是命名问题的最高依据,改了名就得回去登记): - AR09 §3.1 术语表新增「数据访问层 dal / DAO」一行 - AR09 §5.6 缩写表新增 DAO / dal —— 原文是「只有下表内的缩写允许使用」, 不登记就是自己破自己的规矩 - PROJECT_STATE.md 新增 D27 记录本次更名决策 验证:全部在 db 副本上做,生产库 data/eai_agentplatform.db 未触碰。 - 等价性对照:拿 HEAD 源码 + 仅改名 造出第二棵树,两棵树各自起 httptest 服务跑同一份探针(60 个 GET + 13 个写/回读,覆盖专员/技能/应用/ 任务/交付物/项目/岗位/考试/知识/积分/管理端只读等),逐端点比对响应体: 73 项里 52 项字节完全一致、21 项仅运行期时间戳不同、内容差异 0。 - 探针非空:往改名后的树注入「SpecialistDAO.List 限 3 条」变异, /api/specialists 立刻被抓出 —— 证明上面那个 0 不是没测到。 - 暂存区自洽:把索引整个导出成源码树,go build / go vet / go test ./... 全绿。 - gofmt:因 import 排序变化而错位的 19 个文件已修;另 2 个文件(skill_definition.go、 seed.go)的格式问题是工作区里别人的在制品带来的,未替其改动。 未纳入本次提交:工作区里正在进行中的「文生语音技能 + 技能展示色/交互卡」 (tts_handlers.go、text_to_speech/manifest.go、skillCatalog.js 等), 以及 router.go / skill_definition.go / seed.go 三个文件里属于该在制品的改动 —— 这三个文件只把「改名那一版」放进索引,工作区原样保留。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// ActionDefinition 原子执行动作定义仓库。
|
||||
type ActionDefinitionDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 全部动作定义(sort_order ASC, id ASC)。
|
||||
//
|
||||
// 这里不替调用方定 state 默认值:列表接口「不传 state 就只看 active」是接口契约,
|
||||
// 由 handler 解析 query 参数后把结果传进来。
|
||||
func (r ActionDefinitionDAO) List(state string) []model.ActionDefinition {
|
||||
q := r.Type(&model.ActionDefinition{})
|
||||
if state != "" {
|
||||
q = q.Where("state = ?", state)
|
||||
}
|
||||
var items []model.ActionDefinition
|
||||
if q.Order("sort_order ASC, id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 取。
|
||||
func (r ActionDefinitionDAO) GetByID(id uint) (model.ActionDefinition, bool) {
|
||||
var a model.ActionDefinition
|
||||
if r.Type(&a).Where("id = ?", id).First(&a) {
|
||||
return a, true
|
||||
}
|
||||
return model.ActionDefinition{}, false
|
||||
}
|
||||
|
||||
// GetByKey 按 key 取(key 上有唯一索引)。
|
||||
func (r ActionDefinitionDAO) GetByKey(key string) (model.ActionDefinition, bool) {
|
||||
var a model.ActionDefinition
|
||||
if r.Type(&a).Where("key = ?", key).First(&a) {
|
||||
return a, true
|
||||
}
|
||||
return model.ActionDefinition{}, false
|
||||
}
|
||||
|
||||
// Insert 新建。
|
||||
func (r ActionDefinitionDAO) Insert(a *model.ActionDefinition) bool {
|
||||
return r.QueryBuilder.Insert(a)
|
||||
}
|
||||
|
||||
// Update 更新。
|
||||
func (r ActionDefinitionDAO) Update(a *model.ActionDefinition) bool {
|
||||
return r.Save(a)
|
||||
}
|
||||
|
||||
// Delete 硬删除。
|
||||
func (r ActionDefinitionDAO) Delete(a *model.ActionDefinition) bool {
|
||||
return r.QueryBuilder.Delete(a)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// Certificate 证书仓库。
|
||||
type CertificateDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByExamRecord 按考试记录取证书。
|
||||
//
|
||||
// 颁发幂等的依据:同一份 exam_record 只发一张证书。
|
||||
// (此前这里叫 GetByUserAndExam、按不存在的 exam_id 列查,一调即 SQL 报错;
|
||||
// 表上只有 exam_record_id,且颁发幂等本来就该按考试记录而不是按用户+考试。)
|
||||
func (r CertificateDAO) GetByExamRecord(examRecordID uint) (model.Certificate, bool) {
|
||||
var c model.Certificate
|
||||
if r.Type(&c).Where("exam_record_id = ?", examRecordID).First(&c) {
|
||||
return c, true
|
||||
}
|
||||
return model.Certificate{}, false
|
||||
}
|
||||
|
||||
// ListAll 取全部证书(issued_at 倒序,管理端全员视图)。
|
||||
func (r CertificateDAO) ListAll() []model.Certificate {
|
||||
var items []model.Certificate
|
||||
if r.Type(&items).Order("issued_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUser 获取某用户的证书列表。
|
||||
func (r CertificateDAO) ListByUser(userID uint) []model.Certificate {
|
||||
var items []model.Certificate
|
||||
if r.Type(&items).Where("user_id = ?", userID).Order("issued_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r CertificateDAO) GetByID(id uint) (model.Certificate, bool) {
|
||||
var c model.Certificate
|
||||
if r.Type(&c).Where("id = ?", id).First(&c) {
|
||||
return c, true
|
||||
}
|
||||
return model.Certificate{}, false
|
||||
}
|
||||
|
||||
// Insert 创建证书。
|
||||
func (r CertificateDAO) Insert(c *model.Certificate) bool {
|
||||
return r.QueryBuilder.Insert(c)
|
||||
}
|
||||
|
||||
// CountByUser 统计某用户的证书数。
|
||||
func (r CertificateDAO) CountByUser(userID uint) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.Certificate{}).Where("user_id = ?", userID).Count(&c)
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package dal 提供统一的数据访问层(Data Access Layer)。
|
||||
//
|
||||
// 所有 handler 必须通过 DAO(Data Access Object)访问数据,禁止直接调用 store.DB。
|
||||
// 核心 QueryBuilder 封装常用查询操作(FindAll / FindBy / FindByID / Create / Update / Delete),
|
||||
// 各实体 DAO 基于 QueryBuilder 构建领域方法。
|
||||
//
|
||||
// 设计原则:
|
||||
// - 只封装数据访问,不封装业务逻辑(业务逻辑留在 handler/service)
|
||||
// - 返回 *gorm.DB 的方法允许链式调用(Where / Order / Limit 等)
|
||||
// - 所有方法统一错误处理(错误已记录日志,调用方通过 bool 判断)
|
||||
package dal
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
)
|
||||
|
||||
// DB 全局数据库句柄(供 DAO 方法使用)。
|
||||
// 实际使用时指向 store.DB。
|
||||
var DB *gorm.DB
|
||||
|
||||
func init() {
|
||||
DB = store.DB
|
||||
}
|
||||
|
||||
// SetDB 设置数据访问层使用的数据库句柄(测试时覆写)。
|
||||
func SetDB(db *gorm.DB) {
|
||||
DB = db
|
||||
}
|
||||
|
||||
// QueryBuilder 通用查询构建器。
|
||||
// 内部包装 *gorm.DB,提供安全的数据访问方法。
|
||||
type QueryBuilder struct {
|
||||
query *gorm.DB
|
||||
}
|
||||
|
||||
// New 创建新的查询构建器(用于新建记录)。
|
||||
func New(model any) *QueryBuilder {
|
||||
return &QueryBuilder{query: DB.Model(model)}
|
||||
}
|
||||
|
||||
// Table 创建基于已有表名的查询构建器(用于批量操作)。
|
||||
func Table(tableName string) *QueryBuilder {
|
||||
return &QueryBuilder{query: DB.Table(tableName)}
|
||||
}
|
||||
|
||||
// base 返回非 nil 的底层查询链。若调用方是零值仓库(内嵌 *QueryBuilder 为 nil),
|
||||
// 则从包级 DB 重新起步,避免 nil 指针解引用。
|
||||
func (q *QueryBuilder) base() *gorm.DB {
|
||||
if q == nil || q.query == nil {
|
||||
return DB
|
||||
}
|
||||
return q.query
|
||||
}
|
||||
|
||||
// Type 指定查询的模型类型(用于 Find 返回)。
|
||||
func (q *QueryBuilder) Type(model any) *QueryBuilder {
|
||||
return &QueryBuilder{query: q.base().Model(model)}
|
||||
}
|
||||
|
||||
// Query 返回当前查询构建器(用于链式调用)。
|
||||
func (q *QueryBuilder) Query() *QueryBuilder {
|
||||
return &QueryBuilder{query: q.base()}
|
||||
}
|
||||
|
||||
// Where 添加 WHERE 条件。
|
||||
func (q *QueryBuilder) Where(cond string, args ...any) *QueryBuilder {
|
||||
return &QueryBuilder{query: q.base().Where(cond, args...)}
|
||||
}
|
||||
|
||||
// Order 添加排序。
|
||||
func (q *QueryBuilder) Order(value string) *QueryBuilder {
|
||||
return &QueryBuilder{query: q.base().Order(value)}
|
||||
}
|
||||
|
||||
// Limit 限制返回数量。
|
||||
func (q *QueryBuilder) Limit(n int) *QueryBuilder {
|
||||
return &QueryBuilder{query: q.base().Limit(n)}
|
||||
}
|
||||
|
||||
// Offset 设置偏移。
|
||||
func (q *QueryBuilder) Offset(n int) *QueryBuilder {
|
||||
return &QueryBuilder{query: q.base().Offset(n)}
|
||||
}
|
||||
|
||||
// Scopes 应用 GORM scopes。
|
||||
func (q *QueryBuilder) Scopes(scopes ...func(*gorm.DB) *gorm.DB) *QueryBuilder {
|
||||
return &QueryBuilder{query: q.base().Scopes(scopes...)}
|
||||
}
|
||||
|
||||
// First 查询第一条记录。返回 (model, found)。
|
||||
func (q *QueryBuilder) First(model any) (found bool) {
|
||||
if err := q.base().First(model).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return false
|
||||
}
|
||||
log.Printf("[dal] First error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Find 查询所有匹配记录。
|
||||
func (q *QueryBuilder) Find(dest any) (found bool) {
|
||||
if err := q.base().Find(dest).Error; err != nil {
|
||||
log.Printf("[dal] Find error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Count 返回匹配记录数。
|
||||
func (q *QueryBuilder) Count() int64 {
|
||||
var c int64
|
||||
q.base().Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// Insert 插入记录。
|
||||
func (q *QueryBuilder) Insert(value any) bool {
|
||||
if err := q.base().Create(value).Error; err != nil {
|
||||
log.Printf("[dal] Insert error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Create 插入记录(Insert 的别名)。
|
||||
func (q *QueryBuilder) Create(value any) bool {
|
||||
return q.Insert(value)
|
||||
}
|
||||
|
||||
// Save 保存记录(插入或更新)。
|
||||
func (q *QueryBuilder) Save(value any) bool {
|
||||
if err := q.base().Save(value).Error; err != nil {
|
||||
log.Printf("[dal] Save error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Updates 按条件更新(只更新提供的非零字段)。
|
||||
func (q *QueryBuilder) Updates(value any) bool {
|
||||
if err := q.base().Updates(value).Error; err != nil {
|
||||
log.Printf("[dal] Updates error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateColumn 更新单列。
|
||||
func (q *QueryBuilder) UpdateColumn(column string, value any) bool {
|
||||
if err := q.base().Update(column, value).Error; err != nil {
|
||||
log.Printf("[dal] UpdateColumn error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Delete 删除记录。
|
||||
func (q *QueryBuilder) Delete(value any) bool {
|
||||
if err := q.base().Delete(value).Error; err != nil {
|
||||
log.Printf("[dal] Delete error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteByID 按主键 ID 删除。
|
||||
func (q *QueryBuilder) DeleteByID(model any, id uint) bool {
|
||||
if err := q.base().Model(model).Where("id = ?", id).Delete(model).Error; err != nil {
|
||||
log.Printf("[dal] DeleteByID error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Raw 执行原始 SQL。
|
||||
func (q *QueryBuilder) Raw(sql string, args ...any) *QueryBuilder {
|
||||
return &QueryBuilder{query: q.base().Raw(sql, args...)}
|
||||
}
|
||||
|
||||
// Scan 将结果扫描到目标结构体。
|
||||
func (q *QueryBuilder) Scan(dest any) bool {
|
||||
if err := q.base().Scan(dest).Error; err != nil {
|
||||
log.Printf("[dal] Scan error: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Inner 获取底层 *gorm.DB(高级用法,慎用)。
|
||||
func (q *QueryBuilder) Inner() *gorm.DB {
|
||||
return q.base()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// Course 课程仓库。
|
||||
type CourseDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 获取课程列表(按条件过滤)。
|
||||
//
|
||||
// status 的三档含义与产品列表一致,调用方直接透传 query 参数即可:
|
||||
// - ""(默认):仅 active —— 员工浏览视角
|
||||
// - "all":管理员维护视角,不按状态过滤
|
||||
// - 其他:按该状态精确过滤
|
||||
func (r CourseDAO) List(category, status string) []model.Course {
|
||||
q := r.Type(&model.Course{})
|
||||
if category != "" {
|
||||
q = q.Where("category = ?", category)
|
||||
}
|
||||
switch status {
|
||||
case "":
|
||||
q = q.Where("status = ?", "active")
|
||||
case "all":
|
||||
default:
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var items []model.Course
|
||||
if q.Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r CourseDAO) GetByID(id uint) (model.Course, bool) {
|
||||
var c model.Course
|
||||
if r.Type(&c).Where("id = ?", id).First(&c) {
|
||||
return c, true
|
||||
}
|
||||
return model.Course{}, false
|
||||
}
|
||||
|
||||
// GetByCode 按编号获取。
|
||||
func (r CourseDAO) GetByCode(code string) (model.Course, bool) {
|
||||
var c model.Course
|
||||
if r.Type(&c).Where("code = ?", code).First(&c) {
|
||||
return c, true
|
||||
}
|
||||
return model.Course{}, false
|
||||
}
|
||||
|
||||
// Insert 创建课程。
|
||||
func (r CourseDAO) Insert(c *model.Course) bool {
|
||||
return r.QueryBuilder.Insert(c)
|
||||
}
|
||||
|
||||
// Update 更新课程。
|
||||
func (r CourseDAO) Update(c *model.Course) bool {
|
||||
return r.Save(c)
|
||||
}
|
||||
|
||||
// Delete 软删除。
|
||||
func (r CourseDAO) Delete(id uint) bool {
|
||||
return r.Type(&model.Course{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"})
|
||||
}
|
||||
|
||||
// CountByCode 按编号统计(唯一性检查)。
|
||||
func (r CourseDAO) CountByCode(code string, excludeID *uint) int64 {
|
||||
var c int64
|
||||
q := r.Inner().Model(&model.Course{}).Where("code = ?", code)
|
||||
if excludeID != nil {
|
||||
q = q.Where("id <> ?", *excludeID)
|
||||
}
|
||||
q.Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// NamesByIDs 批量解析课程名称(id → name),用于列表页回填关联名称。
|
||||
// 未命中的 ID 不会出现在返回的 map 中,调用方需自行兜底。
|
||||
func (r CourseDAO) NamesByIDs(ids []uint) map[uint]string {
|
||||
names := map[uint]string{}
|
||||
if len(ids) == 0 {
|
||||
return names
|
||||
}
|
||||
var items []model.Course
|
||||
if r.Type(&items).Where("id IN ?", ids).Find(&items) {
|
||||
for _, c := range items {
|
||||
names[c.ID] = c.Name
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// Department 部门仓库。
|
||||
type DepartmentDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 获取部门列表。
|
||||
func (r DepartmentDAO) List() []model.Department {
|
||||
var items []model.Department
|
||||
if r.Type(&items).Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByStatus 按状态取部门列表(id 升序)。
|
||||
//
|
||||
// status 沿用本站列表的档位约定(与 CourseDAO.List 一致):
|
||||
//
|
||||
// "" → 只看 active(前台字典默认)
|
||||
// "all" → 不过滤(管理员维护全量)
|
||||
// 其它 → 按该 status 过滤
|
||||
func (r DepartmentDAO) ListByStatus(status string) []model.Department {
|
||||
q := r.Type(&model.Department{})
|
||||
switch status {
|
||||
case "":
|
||||
q = q.Where("status = ?", "active")
|
||||
case "all":
|
||||
// 不过滤
|
||||
default:
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var items []model.Department
|
||||
if q.Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByName 统计同名部门数;excludeID 非空时排除该条(改名查重用)。
|
||||
func (r DepartmentDAO) CountByName(name string, excludeID *uint) int64 {
|
||||
q := r.Inner().Model(&model.Department{}).Where("name = ?", name)
|
||||
if excludeID != nil {
|
||||
q = q.Where("id <> ?", *excludeID)
|
||||
}
|
||||
var c int64
|
||||
q.Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r DepartmentDAO) GetByID(id uint) (model.Department, bool) {
|
||||
var d model.Department
|
||||
if r.Type(&d).Where("id = ?", id).First(&d) {
|
||||
return d, true
|
||||
}
|
||||
return model.Department{}, false
|
||||
}
|
||||
|
||||
// GetByName 按名称获取。
|
||||
func (r DepartmentDAO) GetByName(name string) (model.Department, bool) {
|
||||
var d model.Department
|
||||
if r.Type(&d).Where("name = ?", name).First(&d) {
|
||||
return d, true
|
||||
}
|
||||
return model.Department{}, false
|
||||
}
|
||||
|
||||
// Insert 创建部门。
|
||||
func (r DepartmentDAO) Insert(d *model.Department) bool {
|
||||
return r.QueryBuilder.Insert(d)
|
||||
}
|
||||
|
||||
// Update 更新部门。
|
||||
func (r DepartmentDAO) Update(d *model.Department) bool {
|
||||
return r.Save(d)
|
||||
}
|
||||
|
||||
// Delete 删除部门。
|
||||
func (r DepartmentDAO) Delete(id uint) bool {
|
||||
return r.DeleteByID(&model.Department{}, id)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// ExamPaper 试卷仓库。
|
||||
type ExamPaperDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 获取全部试卷(id 升序,含已停用)—— 管理端考试配置列表。
|
||||
func (r ExamPaperDAO) List() []model.ExamPaper {
|
||||
var items []model.ExamPaper
|
||||
if r.Type(&items).Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r ExamPaperDAO) GetByID(id uint) (model.ExamPaper, bool) {
|
||||
var p model.ExamPaper
|
||||
if r.Type(&p).Where("id = ?", id).First(&p) {
|
||||
return p, true
|
||||
}
|
||||
return model.ExamPaper{}, false
|
||||
}
|
||||
|
||||
// GetActive 获取所有激活的试卷。
|
||||
func (r ExamPaperDAO) GetActive() []model.ExamPaper {
|
||||
var items []model.ExamPaper
|
||||
if r.Type(&items).Where("status = ?", "active").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Insert 创建试卷。
|
||||
func (r ExamPaperDAO) Insert(p *model.ExamPaper) bool {
|
||||
return r.QueryBuilder.Insert(p)
|
||||
}
|
||||
|
||||
// Update 更新试卷。
|
||||
func (r ExamPaperDAO) Update(p *model.ExamPaper) bool {
|
||||
return r.Save(p)
|
||||
}
|
||||
|
||||
// Delete 软删除。
|
||||
func (r ExamPaperDAO) Delete(id uint) bool {
|
||||
return r.Type(&model.ExamPaper{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"})
|
||||
}
|
||||
|
||||
// Count 统计试卷数。
|
||||
func (r ExamPaperDAO) Count() int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.ExamPaper{}).Count(&c)
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// ExamRecord 考试记录仓库。
|
||||
type ExamRecordDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 获取考试记录列表。
|
||||
func (r ExamRecordDAO) List(userIDStr, paperIDStr string) []model.ExamRecord {
|
||||
q := r.Type(&model.ExamRecord{})
|
||||
if userIDStr != "" {
|
||||
if uid, err := strconv.ParseUint(userIDStr, 10, 32); err == nil {
|
||||
q = q.Where("user_id = ?", uint(uid))
|
||||
}
|
||||
}
|
||||
if paperIDStr != "" {
|
||||
if pid, err := strconv.ParseUint(paperIDStr, 10, 32); err == nil {
|
||||
q = q.Where("paper_id = ?", uint(pid))
|
||||
}
|
||||
}
|
||||
var items []model.ExamRecord
|
||||
if q.Order("submitted_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r ExamRecordDAO) GetByID(id uint) (model.ExamRecord, bool) {
|
||||
var rec model.ExamRecord
|
||||
if r.Type(&rec).Where("id = ?", id).First(&rec) {
|
||||
return rec, true
|
||||
}
|
||||
return model.ExamRecord{}, false
|
||||
}
|
||||
|
||||
// Insert 创建记录。
|
||||
func (r ExamRecordDAO) Insert(rec *model.ExamRecord) bool {
|
||||
return r.QueryBuilder.Insert(rec)
|
||||
}
|
||||
|
||||
// Update 更新记录。
|
||||
func (r ExamRecordDAO) Update(rec *model.ExamRecord) bool {
|
||||
return r.Save(rec)
|
||||
}
|
||||
|
||||
// Remove 删除记录。
|
||||
func (r ExamRecordDAO) Remove(id uint) bool {
|
||||
return r.DeleteByID(&model.ExamRecord{}, id)
|
||||
}
|
||||
|
||||
// CountByUser 统计某用户的考试记录数。
|
||||
func (r ExamRecordDAO) CountByUser(userID uint) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.ExamRecord{}).Where("user_id = ?", userID).Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// HasTaken 判断用户是否已参加过某张试卷(正式考的唯一性依据)。
|
||||
// 试卷列表标注「已完成」、开考拦截、交卷拦截三处共用同一语义。
|
||||
func (r ExamRecordDAO) HasTaken(userID, paperID uint) bool {
|
||||
var c int64
|
||||
r.Inner().Model(&model.ExamRecord{}).
|
||||
Where("user_id = ? AND paper_id = ?", userID, paperID).
|
||||
Count(&c)
|
||||
return c > 0
|
||||
}
|
||||
|
||||
// ListByUser 取某用户的考试记录(按提交时间倒序)。
|
||||
func (r ExamRecordDAO) ListByUser(userID uint) []model.ExamRecord {
|
||||
var items []model.ExamRecord
|
||||
if r.Type(&items).Where("user_id = ?", userID).Order("submitted_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUserChronological 取某用户的考试记录(按提交时间正序)。
|
||||
//
|
||||
// 与 ListByUser 只差排序方向:列表页要最新在前,成绩趋势图要按时间从左到右。
|
||||
func (r ExamRecordDAO) ListByUserChronological(userID uint) []model.ExamRecord {
|
||||
var items []model.ExamRecord
|
||||
if r.Type(&items).Where("user_id = ?", userID).Order("submitted_at ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAll 取全部考试记录(按提交时间倒序,供管理员导出与统计)。
|
||||
func (r ExamRecordDAO) ListAll() []model.ExamRecord {
|
||||
var items []model.ExamRecord
|
||||
if r.Type(&items).Order("submitted_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// KnowledgeChunk 知识分片仓库。
|
||||
type KnowledgeChunkDAO struct{ *QueryBuilder }
|
||||
|
||||
// ApprovedMetaMaps 批量加载解析分片元信息所需的「已审批」素材与知识源(id → 记录)。
|
||||
//
|
||||
// resolveChunkMeta 只认已审批的来源(素材 status=approved、知识源 audit_status=approved),
|
||||
// 所以这里的过滤条件必须与 resolveChunkMeta 内的判断保持一致:**传空 map 会让所有带
|
||||
// media_file_id / knowledge_source_id 的分片被整段丢弃**(历史上 loadKnowledgeCandidates
|
||||
// 就踩过这个坑,AI 检索候选集只剩「无指针」分片)。
|
||||
// AI 检索候选与知识索引构建共用此方法,避免两处各自手写过滤条件而漂移。
|
||||
func (r KnowledgeChunkDAO) ApprovedMetaMaps() (map[uint]model.MediaFile, map[uint]model.KnowledgeSource) {
|
||||
var mediaFiles []model.MediaFile
|
||||
r.Type(&mediaFiles).Where("status = ?", "approved").Find(&mediaFiles)
|
||||
mediaMap := make(map[uint]model.MediaFile, len(mediaFiles))
|
||||
for _, m := range mediaFiles {
|
||||
mediaMap[m.ID] = m
|
||||
}
|
||||
|
||||
var sources []model.KnowledgeSource
|
||||
r.Type(&sources).Where("audit_status = ?", "approved").Find(&sources)
|
||||
sourceMap := make(map[uint]model.KnowledgeSource, len(sources))
|
||||
for _, s := range sources {
|
||||
sourceMap[s.ID] = s
|
||||
}
|
||||
return mediaMap, sourceMap
|
||||
}
|
||||
|
||||
// CountByMediaFile 统计某素材切出的分片数(素材状态页用于展示提取结果)。
|
||||
func (r KnowledgeChunkDAO) CountByMediaFile(mediaID uint) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", mediaID).Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r KnowledgeChunkDAO) GetByID(id uint) (model.KnowledgeChunk, bool) {
|
||||
var c model.KnowledgeChunk
|
||||
if r.Type(&c).Where("id = ?", id).First(&c) {
|
||||
return c, true
|
||||
}
|
||||
return model.KnowledgeChunk{}, false
|
||||
}
|
||||
|
||||
// Insert 创建分片。
|
||||
func (r KnowledgeChunkDAO) Insert(c *model.KnowledgeChunk) bool {
|
||||
return r.QueryBuilder.Insert(c)
|
||||
}
|
||||
|
||||
// BulkInsert 批量创建分片。
|
||||
func (r KnowledgeChunkDAO) BulkInsert(items []model.KnowledgeChunk) int {
|
||||
count := 0
|
||||
for i := range items {
|
||||
if r.QueryBuilder.Insert(&items[i]) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Update 更新分片。
|
||||
func (r KnowledgeChunkDAO) Update(c *model.KnowledgeChunk) bool {
|
||||
return r.Save(c)
|
||||
}
|
||||
|
||||
// DeleteByID 按 ID 删除。
|
||||
func (r KnowledgeChunkDAO) RemoveByID(id uint) bool {
|
||||
return r.DeleteByID(&model.KnowledgeChunk{}, id)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// KnowledgeFAQ FAQ 仓库。
|
||||
//
|
||||
// 知识空间一律用 knowledge_space_key 字符串关联,表上没有 space_id。
|
||||
type KnowledgeFAQDAO struct{ *QueryBuilder }
|
||||
|
||||
// ListForAdmin 后台 FAQ 列表(分页,sort_order 升序、id 倒序)。
|
||||
//
|
||||
// spaceKey / status / keyword 为空表示不过滤该维度。page 从 1 起,
|
||||
// size 由调用方校验后传入。
|
||||
func (r KnowledgeFAQDAO) ListForAdmin(spaceKey, status, keyword string, page, size int) (int64, []model.KnowledgeFAQ) {
|
||||
q := r.Inner().Model(&model.KnowledgeFAQ{})
|
||||
if spaceKey != "" {
|
||||
q = q.Where("knowledge_space_key = ?", spaceKey)
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
q = q.Where("question LIKE ? OR answer LIKE ? OR keywords LIKE ?", like, like, like)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var items []model.KnowledgeFAQ
|
||||
q.Order("sort_order ASC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items)
|
||||
return total, items
|
||||
}
|
||||
|
||||
// ActiveCandidates 取某个知识空间下可用于问答匹配的 FAQ 候选池。
|
||||
//
|
||||
// 口径:只取 status=active;spaceKey 为空 / general / all 时取全量,
|
||||
// 否则取该空间 + general 两级(通用 FAQ 对所有空间生效)。
|
||||
// 打分与命中计数由调用方负责,仓库只负责把候选池的口径固定下来。
|
||||
func (r KnowledgeFAQDAO) ActiveCandidates(spaceKey string) []model.KnowledgeFAQ {
|
||||
q := r.Type(&model.KnowledgeFAQ{}).Where("status = ?", "active")
|
||||
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
|
||||
q = q.Where("knowledge_space_key IN ?", []string{spaceKey, "general"})
|
||||
}
|
||||
var items []model.KnowledgeFAQ
|
||||
if q.Order("sort_order ASC, id DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrHit 命中计数 +1。
|
||||
func (r KnowledgeFAQDAO) IncrHit(id uint) bool {
|
||||
return r.Type(&model.KnowledgeFAQ{}).Where("id = ?", id).
|
||||
UpdateColumn("hit_count", gorm.Expr("hit_count + ?", 1))
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r KnowledgeFAQDAO) GetByID(id uint) (model.KnowledgeFAQ, bool) {
|
||||
var f model.KnowledgeFAQ
|
||||
if r.Type(&f).Where("id = ?", id).First(&f) {
|
||||
return f, true
|
||||
}
|
||||
return model.KnowledgeFAQ{}, false
|
||||
}
|
||||
|
||||
// Insert 创建 FAQ。
|
||||
func (r KnowledgeFAQDAO) Insert(f *model.KnowledgeFAQ) bool {
|
||||
return r.QueryBuilder.Insert(f)
|
||||
}
|
||||
|
||||
// Update 更新 FAQ(整字段覆盖,调用方需带回原主键与创建时间)。
|
||||
func (r KnowledgeFAQDAO) Update(f *model.KnowledgeFAQ) bool {
|
||||
return r.Save(f)
|
||||
}
|
||||
|
||||
// Delete 物理删除 FAQ。
|
||||
//
|
||||
// 与本站其它对象的「软删除(置 status)」不同,FAQ 是直接删行:
|
||||
// 沿用原有行为,未改。若要改成软删需先确认后台 UI 与命中统计口径。
|
||||
func (r KnowledgeFAQDAO) Delete(id uint) bool {
|
||||
return r.DeleteByID(&model.KnowledgeFAQ{}, id)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// KnowledgeSource 知识源仓库。
|
||||
//
|
||||
// 注意状态列是 audit_status(pending/approved/rejected),不是通用的 status;
|
||||
// 表上也没有 space_id —— 知识空间一律用 knowledge_space_key 字符串关联。
|
||||
type KnowledgeSourceDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r KnowledgeSourceDAO) GetByID(id uint) (model.KnowledgeSource, bool) {
|
||||
var s model.KnowledgeSource
|
||||
if r.Type(&s).Where("id = ?", id).First(&s) {
|
||||
return s, true
|
||||
}
|
||||
return model.KnowledgeSource{}, false
|
||||
}
|
||||
|
||||
// GetByFilePath 按文件名获取知识源。
|
||||
//
|
||||
// 扫描入库时用它去重:file_path 存的是知识源目录下的文件名,不是绝对路径。
|
||||
func (r KnowledgeSourceDAO) GetByFilePath(name string) (model.KnowledgeSource, bool) {
|
||||
var s model.KnowledgeSource
|
||||
if r.Type(&s).Where("file_path = ?", name).First(&s) {
|
||||
return s, true
|
||||
}
|
||||
return model.KnowledgeSource{}, false
|
||||
}
|
||||
|
||||
// ListForAudit 知识源审批列表(分页,id 升序)。
|
||||
//
|
||||
// status / spaceKey 为空表示不过滤该维度。page 从 1 起,size 由调用方校验后传入。
|
||||
func (r KnowledgeSourceDAO) ListForAudit(status, spaceKey string, page, size int) (int64, []model.KnowledgeSource) {
|
||||
q := r.Inner().Model(&model.KnowledgeSource{})
|
||||
if status != "" {
|
||||
q = q.Where("audit_status = ?", status)
|
||||
}
|
||||
if spaceKey != "" {
|
||||
q = q.Where("knowledge_space_key = ?", spaceKey)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var items []model.KnowledgeSource
|
||||
q.Order("id ASC").Offset((page - 1) * size).Limit(size).Find(&items)
|
||||
return total, items
|
||||
}
|
||||
|
||||
// ListApproved 全部已审批知识源(id 升序)。
|
||||
//
|
||||
// 供知识空间统计与检索元信息解析使用;调用方通常只需要 id → 记录 的映射。
|
||||
func (r KnowledgeSourceDAO) ListApproved() []model.KnowledgeSource {
|
||||
var items []model.KnowledgeSource
|
||||
if r.Type(&items).Where("audit_status = ?", "approved").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Insert 创建知识源。
|
||||
func (r KnowledgeSourceDAO) Insert(s *model.KnowledgeSource) bool {
|
||||
return r.QueryBuilder.Insert(s)
|
||||
}
|
||||
|
||||
// Update 更新知识源(整字段覆盖,调用方需带回原主键与创建时间)。
|
||||
func (r KnowledgeSourceDAO) Update(s *model.KnowledgeSource) bool {
|
||||
return r.Save(s)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// KnowledgeSpace 知识空间仓库。
|
||||
type KnowledgeSpaceDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 获取知识空间列表。
|
||||
func (r KnowledgeSpaceDAO) List(status string) []model.KnowledgeSpace {
|
||||
q := r.Type(&model.KnowledgeSpace{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var items []model.KnowledgeSpace
|
||||
if q.Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r KnowledgeSpaceDAO) GetByID(id uint) (model.KnowledgeSpace, bool) {
|
||||
var s model.KnowledgeSpace
|
||||
if r.Type(&s).Where("id = ?", id).First(&s) {
|
||||
return s, true
|
||||
}
|
||||
return model.KnowledgeSpace{}, false
|
||||
}
|
||||
|
||||
// GetByName 按名称获取。
|
||||
func (r KnowledgeSpaceDAO) GetByName(name string) (model.KnowledgeSpace, bool) {
|
||||
var s model.KnowledgeSpace
|
||||
if r.Type(&s).Where("name = ?", name).First(&s) {
|
||||
return s, true
|
||||
}
|
||||
return model.KnowledgeSpace{}, false
|
||||
}
|
||||
|
||||
// Insert 创建知识空间。
|
||||
func (r KnowledgeSpaceDAO) Insert(s *model.KnowledgeSpace) bool {
|
||||
return r.QueryBuilder.Insert(s)
|
||||
}
|
||||
|
||||
// Update 更新知识空间。
|
||||
func (r KnowledgeSpaceDAO) Update(s *model.KnowledgeSpace) bool {
|
||||
return r.Save(s)
|
||||
}
|
||||
|
||||
// Delete 软删除。
|
||||
func (r KnowledgeSpaceDAO) Delete(id uint) bool {
|
||||
return r.Type(&model.KnowledgeSpace{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"})
|
||||
}
|
||||
|
||||
// CountByName 按名称统计(唯一性检查)。
|
||||
func (r KnowledgeSpaceDAO) CountByName(name string, excludeID *uint) int64 {
|
||||
q := r.Inner().Model(&model.KnowledgeSpace{}).Where("name = ?", name)
|
||||
if excludeID != nil {
|
||||
q = q.Where("id <> ?", *excludeID)
|
||||
}
|
||||
var c int64
|
||||
q.Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// CountByID 按 ID 统计。
|
||||
func (r KnowledgeSpaceDAO) Count() int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.KnowledgeSpace{}).Count(&c)
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// LearningProgress 学习进度仓库。
|
||||
type LearningProgressDAO struct{ *QueryBuilder }
|
||||
|
||||
// Get 获取用户某条学习进度。
|
||||
func (r LearningProgressDAO) Get(userID uint, itemType string, itemID uint) (model.LearningProgress, bool) {
|
||||
var p model.LearningProgress
|
||||
if r.Type(&p).Where("user_id = ? AND item_type = ? AND item_id = ?", userID, itemType, itemID).First(&p) {
|
||||
return p, true
|
||||
}
|
||||
return model.LearningProgress{}, false
|
||||
}
|
||||
|
||||
// ListByUser 获取某用户的所有学习进度。
|
||||
func (r LearningProgressDAO) ListByUser(userID uint) []model.LearningProgress {
|
||||
var items []model.LearningProgress
|
||||
if r.Type(&items).Where("user_id = ?", userID).Order("updated_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAll 取全部学习进度(updated_at 倒序,管理端全员视图)。
|
||||
func (r LearningProgressDAO) ListAll() []model.LearningProgress {
|
||||
var items []model.LearningProgress
|
||||
if r.Type(&items).Order("updated_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upsert 插入或更新学习进度。
|
||||
func (r LearningProgressDAO) Upsert(p *model.LearningProgress) bool {
|
||||
existing, found := r.Get(p.UserID, p.ItemType, p.ItemID)
|
||||
if !found {
|
||||
return r.Insert(p)
|
||||
}
|
||||
existing.UpdatedAt = time.Now()
|
||||
return r.Save(&existing)
|
||||
}
|
||||
|
||||
// Record 记录一条学习进度。
|
||||
func (r LearningProgressDAO) Record(userID uint, itemType string, itemID uint) bool {
|
||||
_, found := r.Get(userID, itemType, itemID)
|
||||
if found {
|
||||
return r.Upsert(&model.LearningProgress{UserID: userID, ItemType: itemType, ItemID: itemID})
|
||||
}
|
||||
return r.Insert(&model.LearningProgress{UserID: userID, ItemType: itemType, ItemID: itemID, CreatedAt: time.Now(), UpdatedAt: time.Now()})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// MediaFile 媒体文件仓库。
|
||||
type MediaFileDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r MediaFileDAO) GetByID(id uint) (model.MediaFile, bool) {
|
||||
var m model.MediaFile
|
||||
if r.Type(&m).Where("id = ?", id).First(&m) {
|
||||
return m, true
|
||||
}
|
||||
return model.MediaFile{}, false
|
||||
}
|
||||
|
||||
// Insert 创建文件记录。
|
||||
func (r MediaFileDAO) Insert(m *model.MediaFile) bool {
|
||||
return r.QueryBuilder.Insert(m)
|
||||
}
|
||||
|
||||
// Update 更新文件记录。
|
||||
func (r MediaFileDAO) Update(m *model.MediaFile) bool {
|
||||
return r.Save(m)
|
||||
}
|
||||
|
||||
// Delete 删除文件记录。
|
||||
func (r MediaFileDAO) Delete(id uint) bool {
|
||||
return r.DeleteByID(&model.MediaFile{}, id)
|
||||
}
|
||||
|
||||
// ListByBind 获取绑定到某实体、且已审批通过的素材(id 升序)。
|
||||
//
|
||||
// 「审批前置」的落点:课程/产品详情只露出 approved 素材,pending 与 rejected 一律不出现在业务页面。
|
||||
// 调用方无需再自己拼 bind_type/bind_id/status 三条件,避免各处漏掉 status 过滤而泄漏未审批素材。
|
||||
func (r MediaFileDAO) ListByBind(bindType string, bindID uint) []model.MediaFile {
|
||||
var items []model.MediaFile
|
||||
if r.Type(&items).
|
||||
Where("bind_type = ? AND bind_id = ? AND status = ?", bindType, bindID, "approved").
|
||||
Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListApprovedByBindType 获取某类绑定下、已审批通过的素材(id 升序)。
|
||||
//
|
||||
// 与 ListByBind 的区别:只按绑定类型取,不限定 bind_id。
|
||||
// 公司介绍课件这类「全局素材」不挂具体实体,bind_id 无意义。
|
||||
func (r MediaFileDAO) ListApprovedByBindType(bindType string) []model.MediaFile {
|
||||
var items []model.MediaFile
|
||||
if r.Type(&items).
|
||||
Where("bind_type = ? AND status = ?", bindType, "approved").
|
||||
Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForAudit 审批列表(分页):返回符合条件的总数与本页记录,created_at 倒序。
|
||||
//
|
||||
// status / spaceKey 为空表示不过滤该维度。page 从 1 起,size 由调用方校验后再传入。
|
||||
func (r MediaFileDAO) ListForAudit(status, spaceKey string, page, size int) (int64, []model.MediaFile) {
|
||||
q := r.Inner().Model(&model.MediaFile{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if spaceKey != "" {
|
||||
q = q.Where("knowledge_space_key = ?", spaceKey)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var items []model.MediaFile
|
||||
q.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items)
|
||||
return total, items
|
||||
}
|
||||
|
||||
// MarkExtracted 标记素材已完成提取(视频/图片仅有此标记,文档由提取管线置位)。
|
||||
func (r MediaFileDAO) MarkExtracted(id uint) bool {
|
||||
return r.Type(&model.MediaFile{}).Where("id = ?", id).UpdateColumn("extracted", true)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// MistakeRecordDAO 错题记录仓库。
|
||||
//
|
||||
// 错题本有两套口径,都收在本仓库里,避免调用方各写各的 Where:
|
||||
// - 入本/再次答错:唯一键 (user_id, question_id, source),见 RecordWrong
|
||||
// - 重练判分回写:按 (user_id, question_id) 扇出,把该题在所有来源下的记录一并更新,见 TouchOnPractice
|
||||
type MistakeRecordDAO struct{ *QueryBuilder }
|
||||
|
||||
// ListByUser 我的错题本(按最近更新倒序)。
|
||||
func (r MistakeRecordDAO) ListByUser(userID uint) []model.MistakeRecord {
|
||||
var items []model.MistakeRecord
|
||||
if r.Type(&items).Where("user_id = ?", userID).Order("updated_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForPractice 错题重练选题:可按来源过滤、可只看未掌握(按最近更新倒序)。
|
||||
func (r MistakeRecordDAO) ListForPractice(userID uint, source string, onlyUnresolved bool) []model.MistakeRecord {
|
||||
q := r.Type(&model.MistakeRecord{}).Where("user_id = ?", userID)
|
||||
if source != "" {
|
||||
q = q.Where("source = ?", source)
|
||||
}
|
||||
if onlyUnresolved {
|
||||
q = q.Where("resolved = ?", false)
|
||||
}
|
||||
var items []model.MistakeRecord
|
||||
if q.Order("updated_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByUser 统计某用户的错题总数。
|
||||
func (r MistakeRecordDAO) CountByUser(userID uint) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.MistakeRecord{}).Where("user_id = ?", userID).Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// CountResolvedByUser 统计某用户已掌握的错题数。
|
||||
func (r MistakeRecordDAO) CountResolvedByUser(userID uint) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.MistakeRecord{}).
|
||||
Where("user_id = ? AND resolved = ?", userID, true).Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r MistakeRecordDAO) GetByID(id uint) (model.MistakeRecord, bool) {
|
||||
var m model.MistakeRecord
|
||||
if r.Type(&m).Where("id = ?", id).First(&m) {
|
||||
return m, true
|
||||
}
|
||||
return model.MistakeRecord{}, false
|
||||
}
|
||||
|
||||
// RecordWrong 答错入本:按唯一键 (user_id, question_id, source) upsert。
|
||||
// 已有记录则整字段覆盖,并把 resolved 重置为未掌握——再次答错视为问题重新暴露。
|
||||
func (r MistakeRecordDAO) RecordWrong(m model.MistakeRecord) bool {
|
||||
m.Resolved = false
|
||||
var old model.MistakeRecord
|
||||
if !r.Type(&old).
|
||||
Where("user_id = ? AND question_id = ? AND source = ?", m.UserID, m.QuestionID, m.Source).
|
||||
First(&old) {
|
||||
return r.Insert(&m)
|
||||
}
|
||||
// Save 是整字段覆盖,必须带上原有主键与创建时间,否则 created_at 会被写成零值。
|
||||
m.ID = old.ID
|
||||
m.CreatedAt = old.CreatedAt
|
||||
return r.Save(&m)
|
||||
}
|
||||
|
||||
// TouchOnPractice 错题重练判分后回写:按 (user_id, question_id) 扇出,更新该题在
|
||||
// 所有来源下的作答与掌握状态。
|
||||
//
|
||||
// 返回 flipped 为本轮由「未掌握 → 已掌握」翻转的记录 ID,调用方据此加积分;
|
||||
// 只返回翻转的 ID(而非全部命中的记录)是为了避免反复重练刷分。
|
||||
// touched=false 表示该题在错题本里一条都没有,调用方需自行兜底补建记录。
|
||||
func (r MistakeRecordDAO) TouchOnPractice(m model.MistakeRecord, resolved bool) (flipped []uint, touched bool) {
|
||||
var recs []model.MistakeRecord
|
||||
if !r.Type(&recs).Where("user_id = ? AND question_id = ?", m.UserID, m.QuestionID).Find(&recs) {
|
||||
return nil, false
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for i := range recs {
|
||||
was := recs[i].Resolved
|
||||
recs[i].Resolved = resolved
|
||||
recs[i].QuestionType = m.QuestionType
|
||||
recs[i].QuestionStem = m.QuestionStem
|
||||
recs[i].UserAnswer = m.UserAnswer
|
||||
recs[i].CorrectAnswer = m.CorrectAnswer
|
||||
recs[i].Explanation = m.Explanation
|
||||
r.Save(&recs[i])
|
||||
if resolved && !was {
|
||||
flipped = append(flipped, recs[i].ID)
|
||||
}
|
||||
}
|
||||
return flipped, true
|
||||
}
|
||||
|
||||
// Insert 创建错题记录。
|
||||
func (r MistakeRecordDAO) Insert(m *model.MistakeRecord) bool {
|
||||
return r.QueryBuilder.Insert(m)
|
||||
}
|
||||
|
||||
// Update 保存已有记录(改掌握状态等)。
|
||||
func (r MistakeRecordDAO) Update(m *model.MistakeRecord) bool {
|
||||
return r.Save(m)
|
||||
}
|
||||
|
||||
// Delete 删除错题记录。
|
||||
func (r MistakeRecordDAO) Delete(id uint) bool {
|
||||
return r.DeleteByID(&model.MistakeRecord{}, id)
|
||||
}
|
||||
|
||||
// DeleteByUser 删除某用户的全部错题。
|
||||
func (r MistakeRecordDAO) DeleteByUser(userID uint) bool {
|
||||
return r.Type(&model.MistakeRecord{}).Where("user_id = ?", userID).Delete(&model.MistakeRecord{})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
oaamodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
)
|
||||
|
||||
// OfficialAccountArticle 公众号文章状态仓库(一个任务一篇,task_id 上有唯一索引)。
|
||||
//
|
||||
// 模型定义在专员技能包内(specialists/packages/wechat_official_account/model),
|
||||
// 原先这个包自己拿 store.DB 读写,收口后统一走这里。
|
||||
type OfficialAccountArticleDAO struct{ *QueryBuilder }
|
||||
|
||||
// FindByTaskID 按任务取文章状态。三种结果,**调用方必须分清**:
|
||||
// - row != nil 找到了
|
||||
// - row == nil, err == nil 该任务还没有文章行,调用方应新建一篇
|
||||
// - err != nil 读取出错,调用方应报错,**不要**当成「还没有」
|
||||
//
|
||||
// 第三种是本仓库要返回 error 而不是 bool 的原因。原实现是
|
||||
// `if err == nil { return article }` 其余一律往下走新建 —— 也就是说读取
|
||||
// 真出错时也会去建一篇新的。task_id 上有唯一索引,这种误建多半会撞唯一键
|
||||
// 而失败,但那是运气不是设计。(同 UserXAppCenterDAO.FindByUser)
|
||||
func (r OfficialAccountArticleDAO) FindByTaskID(taskID uint) (*oaamodel.OfficialAccountArticle, error) {
|
||||
var row oaamodel.OfficialAccountArticle
|
||||
err := r.Inner().Where("task_id = ?", taskID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// Insert 新建文章状态。
|
||||
func (r OfficialAccountArticleDAO) Insert(a *oaamodel.OfficialAccountArticle) bool {
|
||||
return r.QueryBuilder.Insert(a)
|
||||
}
|
||||
|
||||
// Update 保存文章状态(改标题/提纲/正文/配图等一律走这里)。
|
||||
func (r OfficialAccountArticleDAO) Update(a *oaamodel.OfficialAccountArticle) bool {
|
||||
return r.Save(a)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
oahmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model"
|
||||
)
|
||||
|
||||
// OfficialAccountHotspot 公众号热点缓存仓库。
|
||||
//
|
||||
// 表里存的是抓取回来的 RSS/网页条目及其领域评分,按 (source_key, url)
|
||||
// 唯一,重复抓到同一条要就地更新而不是堆新行。
|
||||
type OfficialAccountHotspotDAO struct{ *QueryBuilder }
|
||||
|
||||
// hotspotListLimit 单次取回的热点条数上限。
|
||||
//
|
||||
// 调用方(loadFreshOfficialAccountHotspots)取回后还要按关键词在内存里再滤一遍,
|
||||
// 所以这里多取一些留余量 —— 这个 40 是原实现写死的值,收口时原样保留。
|
||||
const hotspotListLimit = 40
|
||||
|
||||
// ListFresh 取某业务域内、抓取时间在 cutoff 之后的、领域分高的热点。
|
||||
//
|
||||
// 排序按「领域分 → 发布时间 → id」降序:领域分是主序(越对口越靠前),
|
||||
// 发布时间用来在同分里挑新鲜的,最后的 id 是稳定排序的兜底 ——
|
||||
// 少了它,同分同时间的记录每次查询顺序可能不一样,分页/去重就会飘。
|
||||
//
|
||||
// 返回空切片表示没有符合条件的热点,这不是错误。
|
||||
//
|
||||
// **注意:读取真出错时同样返回空**,调用方分不出「没有」和「读失败」。
|
||||
// 收口前这里返回 error,读失败会让整个热点步骤失败;现在降级成「本轮没有热点」,
|
||||
// 调用方会走到不依赖热点的兜底选题路径。这个降级是有意接受的 ——
|
||||
// 数据访问层统一是 bool / 裸返回的风格(同 TaskRunDAO.ListByTask),
|
||||
// 不为一次缓存读再造一个 error 出口;但这是**行为变化**,不是等价重构。
|
||||
func (r OfficialAccountHotspotDAO) ListFresh(domainKey string, cutoff time.Time) []oahmodel.OfficialAccountHotspot {
|
||||
var items []oahmodel.OfficialAccountHotspot
|
||||
if r.Type(&items).
|
||||
Where("business_domain = ? AND fetched_at >= ? AND domain_score > 0", domainKey, cutoff).
|
||||
Order("domain_score DESC, published_at DESC, id DESC").
|
||||
Limit(hotspotListLimit).
|
||||
Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertAll 批量写入热点,按 (source_key, url) 冲突就地更新。
|
||||
//
|
||||
// 这里返回 error 而不是 bool,是因为调用方要把 err.Error() 拼进给用户看的
|
||||
// 抓取日志(「热点入库失败:…」)——bool 装不下这句话,丢掉它用户就只看到
|
||||
// 「本轮未写入新热点」,无从知道是库的问题还是源站的问题。
|
||||
func (r OfficialAccountHotspotDAO) UpsertAll(rows []oahmodel.OfficialAccountHotspot) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.Inner().Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_key"}, {Name: "url"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"title", "summary", "published_at", "raw_score", "domain_score",
|
||||
"heat_label", "tags_json", "fetched_at", "updated_at",
|
||||
}),
|
||||
}).Create(&rows).Error
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// Position 岗位仓库。
|
||||
type PositionDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 获取岗位列表。
|
||||
func (r PositionDAO) List(status string) []model.Position {
|
||||
q := r.Type(&model.Position{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
} else {
|
||||
q = q.Where("status = ?", "active")
|
||||
}
|
||||
var items []model.Position
|
||||
if q.Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r PositionDAO) GetByID(id uint) (model.Position, bool) {
|
||||
var p model.Position
|
||||
if r.Type(&p).Where("id = ?", id).First(&p) {
|
||||
return p, true
|
||||
}
|
||||
return model.Position{}, false
|
||||
}
|
||||
|
||||
// GetByName 按名称获取。
|
||||
func (r PositionDAO) GetByName(name string) (model.Position, bool) {
|
||||
var p model.Position
|
||||
if r.Type(&p).Where("name = ?", name).First(&p) {
|
||||
return p, true
|
||||
}
|
||||
return model.Position{}, false
|
||||
}
|
||||
|
||||
// Insert 创建岗位。
|
||||
func (r PositionDAO) Insert(p *model.Position) bool {
|
||||
return r.QueryBuilder.Insert(p)
|
||||
}
|
||||
|
||||
// Update 更新岗位。
|
||||
func (r PositionDAO) Update(p *model.Position) bool {
|
||||
return r.Save(p)
|
||||
}
|
||||
|
||||
// Delete 软删除。
|
||||
func (r PositionDAO) Delete(id uint) bool {
|
||||
return r.Type(&model.Position{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"})
|
||||
}
|
||||
|
||||
// CountByDepartment 统计某部门的岗位数。
|
||||
func (r PositionDAO) CountByDepartment(departmentID uint) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.Position{}).Where("department_id = ?", departmentID).Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// CountByName 按名称统计(唯一性检查)。
|
||||
func (r PositionDAO) CountByName(name string, excludeID *uint) int64 {
|
||||
q := r.Inner().Model(&model.Position{}).Where("name = ?", name)
|
||||
if excludeID != nil {
|
||||
q = q.Where("id <> ?", *excludeID)
|
||||
}
|
||||
var c int64
|
||||
q.Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// ============ 岗位知识映射(PositionKnowledge) ============
|
||||
|
||||
// Knowledge 取岗位知识映射(按 id ASC)。学员应学清单与管理员编辑页共用。
|
||||
func (r PositionDAO) Knowledge(positionID uint) []model.PositionKnowledge {
|
||||
var items []model.PositionKnowledge
|
||||
if r.Type(&items).Where("position_id = ?", positionID).Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceKnowledge 整表覆盖岗位知识映射(先删旧、再批量插入)。
|
||||
// 两步非原子:调用方若需强一致,应在事务中执行。
|
||||
func (r PositionDAO) ReplaceKnowledge(positionID uint, rows []model.PositionKnowledge) bool {
|
||||
if !r.Type(&model.PositionKnowledge{}).Where("position_id = ?", positionID).
|
||||
Delete(&model.PositionKnowledge{}) {
|
||||
return false
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return true
|
||||
}
|
||||
return r.Type(&rows).Create(&rows)
|
||||
}
|
||||
|
||||
// CountKnowledge 统计岗位知识映射条数。
|
||||
func (r PositionDAO) CountKnowledge(positionID uint) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.PositionKnowledge{}).Where("position_id = ?", positionID).Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// ============ 岗位考试蓝图(PositionExamBlueprint) ============
|
||||
|
||||
// Blueprints 取岗位考试蓝图(按 id ASC)。
|
||||
func (r PositionDAO) Blueprints(positionID uint) []model.PositionExamBlueprint {
|
||||
var items []model.PositionExamBlueprint
|
||||
if r.Type(&items).Where("position_id = ?", positionID).Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceBlueprints 整表覆盖岗位考试蓝图(先删旧、再批量插入)。
|
||||
func (r PositionDAO) ReplaceBlueprints(positionID uint, rows []model.PositionExamBlueprint) bool {
|
||||
if !r.Type(&model.PositionExamBlueprint{}).Where("position_id = ?", positionID).
|
||||
Delete(&model.PositionExamBlueprint{}) {
|
||||
return false
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return true
|
||||
}
|
||||
return r.Type(&rows).Create(&rows)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// Product 产品仓库。
|
||||
type ProductDAO struct{ *QueryBuilder }
|
||||
|
||||
// Query 返回基础查询构建器(供复合查询使用)。
|
||||
func (r ProductDAO) Query() *QueryBuilder {
|
||||
return r.Type(&model.Product{})
|
||||
}
|
||||
|
||||
// List 获取产品列表(按条件过滤)。
|
||||
func (r ProductDAO) List(conds ...map[string]any) []model.Product {
|
||||
q := r.QueryBuilder.Type(&model.Product{})
|
||||
for _, c := range conds {
|
||||
for k, v := range c {
|
||||
q = q.Where(k+" = ?", v)
|
||||
}
|
||||
}
|
||||
var items []model.Product
|
||||
if q.Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取产品。
|
||||
func (r ProductDAO) GetByID(id uint) (model.Product, bool) {
|
||||
var p model.Product
|
||||
if r.Type(&p).Where("id = ?", id).First(&p) {
|
||||
return p, true
|
||||
}
|
||||
return model.Product{}, false
|
||||
}
|
||||
|
||||
// GetVisibleByID 按 ID 获取「可对外露出」的产品。
|
||||
//
|
||||
// 与 GetByID 的区别是这里的业务口径:已停用(status=inactive)的产品不再出现在
|
||||
// 课程详情这类业务页面上。此前该判断散落在 handler 里手写,容易被漏掉或写歪。
|
||||
func (r ProductDAO) GetVisibleByID(id uint) (model.Product, bool) {
|
||||
var p model.Product
|
||||
if r.Type(&p).Where("id = ? AND status != ?", id, "inactive").First(&p) {
|
||||
return p, true
|
||||
}
|
||||
return model.Product{}, false
|
||||
}
|
||||
|
||||
// GetByCode 按编号获取产品。
|
||||
func (r ProductDAO) GetByCode(code string) (model.Product, bool) {
|
||||
var p model.Product
|
||||
if r.Type(&p).Where("code = ?", code).First(&p) {
|
||||
return p, true
|
||||
}
|
||||
return model.Product{}, false
|
||||
}
|
||||
|
||||
// Insert 创建产品。
|
||||
func (r ProductDAO) Insert(p *model.Product) bool {
|
||||
return r.QueryBuilder.Insert(p)
|
||||
}
|
||||
|
||||
// Update 更新产品。
|
||||
func (r ProductDAO) Update(p *model.Product) bool {
|
||||
return r.Save(p)
|
||||
}
|
||||
|
||||
// Delete 删除产品(软删除,status=inactive)。
|
||||
//
|
||||
// 这里必须写 "inactive" 而不是另造一个 "deleted":产品状态是与课程共用的
|
||||
// active/inactive 两档词汇,POST /products/{id} 的 DELETE 处理器也据此向调用方
|
||||
// 回包 {"status":"inactive"}。此前写成 "deleted" 会让接口回包与库内实际值不一致,
|
||||
// 管理员用 status=all 拉列表时会看到一个前端不认识的状态。
|
||||
func (r ProductDAO) Delete(id uint) bool {
|
||||
return r.Type(&model.Product{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"})
|
||||
}
|
||||
|
||||
// CountByCode 按编号统计(用于唯一性检查)。
|
||||
func (r ProductDAO) CountByCode(code string, excludeID *uint) int64 {
|
||||
q := r.Inner().Model(&model.Product{}).Where("code = ?", code)
|
||||
if excludeID != nil {
|
||||
q = q.Where("id <> ?", *excludeID)
|
||||
}
|
||||
var c int64
|
||||
q.Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// NamesByIDs 批量解析产品名称(id → name),用于列表页回填关联名称。
|
||||
// 未命中的 ID 不会出现在返回的 map 中,调用方需自行兜底。
|
||||
func (r ProductDAO) NamesByIDs(ids []uint) map[uint]string {
|
||||
names := map[uint]string{}
|
||||
if len(ids) == 0 {
|
||||
return names
|
||||
}
|
||||
var items []model.Product
|
||||
if r.Type(&items).Where("id IN ?", ids).Find(&items) {
|
||||
for _, p := range items {
|
||||
names[p.ID] = p.Name
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ProductsByStatus 按状态筛选产品列表。
|
||||
func (r ProductDAO) ProductsByStatus(status string, filter map[string]string) []model.Product {
|
||||
q := r.Type(&model.Product{})
|
||||
if cat, ok := filter["category"]; ok {
|
||||
q = q.Where("category = ?", cat)
|
||||
}
|
||||
switch status {
|
||||
case "": // 默认仅 active(员工浏览)
|
||||
q = q.Where("status = ?", "active")
|
||||
case "all": // 管理员维护全量
|
||||
default:
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var items []model.Product
|
||||
if q.Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// Project 项目仓库(任务的容器)。
|
||||
type ProjectDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByIDForOwners 按 ID 取项目,且必须归属于 owners 里的一员。
|
||||
//
|
||||
// owners 为空时查不到任何东西(而不是查到全部)——跟任务同一套归属口径,
|
||||
// 别人的项目和不存在的项目在这里不做区分,调用方一律回 404。
|
||||
func (r ProjectDAO) GetByIDForOwners(id uint, owners []string) (model.Project, bool) {
|
||||
var p model.Project
|
||||
if ownerScope(r.Type(&p), owners).Where("id = ?", id).First(&p) {
|
||||
return p, true
|
||||
}
|
||||
return model.Project{}, false
|
||||
}
|
||||
|
||||
// ListByOwners 「我的项目」:置顶的排前面,其余按最近动过的排。
|
||||
// limit <= 0 表示不设上限。
|
||||
func (r ProjectDAO) ListByOwners(owners []string, limit int) []model.Project {
|
||||
q := ownerScope(r.Type(&model.Project{}), owners)
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
var items []model.Project
|
||||
if q.Order("pinned DESC, updated_at DESC, id DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Insert 新建项目。
|
||||
func (r ProjectDAO) Insert(p *model.Project) bool {
|
||||
return r.QueryBuilder.Insert(p)
|
||||
}
|
||||
|
||||
// Update 更新项目。
|
||||
func (r ProjectDAO) Update(p *model.Project) bool {
|
||||
return r.Save(p)
|
||||
}
|
||||
|
||||
// Delete 删除项目。
|
||||
//
|
||||
// 项目里的任务**不删**——先由调用方把它们的 project_id 置空(见
|
||||
// TaskRecordDAO.ClearProject),再删项目本身。
|
||||
func (r ProjectDAO) Delete(p *model.Project) bool {
|
||||
return r.QueryBuilder.Delete(p)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// Question 题目仓库。
|
||||
type QuestionDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 获取题目列表(id 升序)。
|
||||
//
|
||||
// status 沿用本站列表的档位约定(与 CourseDAO.List 一致):
|
||||
//
|
||||
// "" → 只看 active(学员侧默认)
|
||||
// "all" → 不过滤(管理端题库默认,含已停用)
|
||||
// 其它 → 按该 status 过滤
|
||||
//
|
||||
// domain 为空表示不过滤该维度。
|
||||
func (r QuestionDAO) List(domain, status string) []model.Question {
|
||||
q := r.Type(&model.Question{})
|
||||
if domain != "" {
|
||||
q = q.Where("domain = ?", domain)
|
||||
}
|
||||
switch status {
|
||||
case "":
|
||||
q = q.Where("status = ?", "active")
|
||||
case "all":
|
||||
// 不过滤
|
||||
default:
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var items []model.Question
|
||||
if q.Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByIDs 按 ID 批量取题,**不过滤 status**(id 升序)。
|
||||
//
|
||||
// 判分专用:考试会话里下发的题目即使事后被停用,也必须能判分,
|
||||
// 否则学员交了卷却判不出来。要「只取可作答题目」用 ListActiveByIDs。
|
||||
func (r QuestionDAO) ListByIDs(ids []uint) []model.Question {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var items []model.Question
|
||||
if r.Type(&items).Where("id IN ?", ids).Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListActiveByIDs 按 ID 批量取「已启用」的题(id 升序)。
|
||||
//
|
||||
// 下发专用:错题重练等场景要剔除已停用/删除的题目。
|
||||
func (r QuestionDAO) ListActiveByIDs(ids []uint) []model.Question {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var items []model.Question
|
||||
if r.Type(&items).Where("id IN ? AND status = ?", ids, "active").Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivePool 返回「岗位应学范围内已启用题目」的查询构建器,供抽题逻辑继续细分。
|
||||
//
|
||||
// 口径(P0 岗位考试抽题,抽题主流程与蓝图抽题共用,避免两处手写漂移):
|
||||
// - 只取 status=active
|
||||
// - domains 非空时限定 domain IN domains
|
||||
// - courseIDs 非空时「题目属于这些课程,或未绑定课程但域匹配」(宽松口径,沿用原行为)
|
||||
//
|
||||
// 两个参数都为空表示全题库。
|
||||
func (r QuestionDAO) ActivePool(domains []string, courseIDs []uint) *QueryBuilder {
|
||||
q := r.Type(&model.Question{}).Where("status = ?", "active")
|
||||
if len(domains) > 0 {
|
||||
q = q.Where("domain IN ?", domains)
|
||||
}
|
||||
if len(courseIDs) > 0 {
|
||||
q = q.Where("course_id IN ? OR course_id IS NULL", courseIDs)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取题目。
|
||||
func (r QuestionDAO) GetByID(id uint) (model.Question, bool) {
|
||||
var q model.Question
|
||||
if r.Type(&q).Where("id = ?", id).First(&q) {
|
||||
return q, true
|
||||
}
|
||||
return model.Question{}, false
|
||||
}
|
||||
|
||||
// GetActiveIDs 获取指定状态的题目 ID 列表。
|
||||
func (r QuestionDAO) GetActiveIDs(ids []uint) []uint {
|
||||
var result []uint
|
||||
r.Inner().Model(&model.Question{}).Where("id IN ? AND status = ?", ids, "active").Pluck("id", &result)
|
||||
return result
|
||||
}
|
||||
|
||||
// DomainMap 返回「题目 ID → 知识域」映射。
|
||||
//
|
||||
// 学员能力雷达要拿答题明细里的题目 ID 反查所属域;只取两列,避免整表加载题干。
|
||||
func (r QuestionDAO) DomainMap() map[uint]string {
|
||||
var rows []model.Question
|
||||
r.Inner().Model(&model.Question{}).Select("id", "domain").Find(&rows)
|
||||
out := make(map[uint]string, len(rows))
|
||||
for _, q := range rows {
|
||||
out[q.ID] = q.Domain
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CountByType 统计某类题目数量。
|
||||
func (r QuestionDAO) CountByType(domain, qType string) int64 {
|
||||
q := r.Inner().Model(&model.Question{})
|
||||
if domain != "" {
|
||||
q = q.Where("domain = ?", domain)
|
||||
}
|
||||
var c int64
|
||||
q.Where("type = ? AND status = ?", qType, "active").Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// Insert 创建题目。
|
||||
func (r QuestionDAO) Insert(item *model.Question) bool {
|
||||
return r.QueryBuilder.Insert(item)
|
||||
}
|
||||
|
||||
// Update 更新题目。
|
||||
func (r QuestionDAO) Update(item *model.Question) bool {
|
||||
return r.Save(item)
|
||||
}
|
||||
|
||||
// Delete 软删除。
|
||||
func (r QuestionDAO) Delete(id uint) bool {
|
||||
return r.Type(&model.Question{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"})
|
||||
}
|
||||
|
||||
// PickRandom 随机抽取 N 道题。
|
||||
func (r QuestionDAO) PickRandom(domain, qType string, count int, excludeIDs []uint) []model.Question {
|
||||
q := r.Type(&model.Question{}).Where("status = ? AND type = ?", "active", qType)
|
||||
if domain != "" {
|
||||
q = q.Where("domain = ?", domain)
|
||||
}
|
||||
if len(excludeIDs) > 0 {
|
||||
q = q.Where("id NOT IN ?", excludeIDs)
|
||||
}
|
||||
var items []model.Question
|
||||
if q.Order("RANDOM()").Limit(count).Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
skillmodel "eai_agentplatform/backend/internal/skills/model"
|
||||
)
|
||||
|
||||
// SkillDefinition 技能定义仓库。
|
||||
//
|
||||
// 技能是对象层三类一级对象之一(专员 / 技能 / 应用),与专员目录一样,
|
||||
// 「按 key 取技能」原先在多个文件里各写一遍,统一收这里。
|
||||
type SkillDefinitionDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 技能定义列表(sort_order ASC, id ASC)。
|
||||
//
|
||||
// state 不在这里定默认值:「不传 state 就只看 active」是列表接口的契约,
|
||||
// 由 handler 解析 query 参数后把结果传进来(与 ActionDefinitionDAO.List 同一约定)。
|
||||
//
|
||||
// exposedToUser 是三态:nil 表示不按该列过滤,非 nil 按值精确匹配。
|
||||
// 不用 bool 是因为「不传该参数」与「传 false」语义不同,前者要全量。
|
||||
func (r SkillDefinitionDAO) List(state string, exposedToUser *bool) []skillmodel.SkillDefinition {
|
||||
q := r.Type(&skillmodel.SkillDefinition{})
|
||||
if state != "" {
|
||||
q = q.Where("state = ?", state)
|
||||
}
|
||||
if exposedToUser != nil {
|
||||
q = q.Where("exposed_to_user = ?", *exposedToUser)
|
||||
}
|
||||
var items []skillmodel.SkillDefinition
|
||||
if q.Order("sort_order ASC, id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 取。
|
||||
func (r SkillDefinitionDAO) GetByID(id uint) (skillmodel.SkillDefinition, bool) {
|
||||
var s skillmodel.SkillDefinition
|
||||
if r.Type(&s).Where("id = ?", id).First(&s) {
|
||||
return s, true
|
||||
}
|
||||
return skillmodel.SkillDefinition{}, false
|
||||
}
|
||||
|
||||
// GetByKey 按 key 取(key 上有唯一索引)。
|
||||
func (r SkillDefinitionDAO) GetByKey(key string) (skillmodel.SkillDefinition, bool) {
|
||||
var s skillmodel.SkillDefinition
|
||||
if r.Type(&s).Where("key = ?", key).First(&s) {
|
||||
return s, true
|
||||
}
|
||||
return skillmodel.SkillDefinition{}, false
|
||||
}
|
||||
|
||||
// Insert 新建技能定义。
|
||||
func (r SkillDefinitionDAO) Insert(s *skillmodel.SkillDefinition) bool {
|
||||
return r.QueryBuilder.Insert(s)
|
||||
}
|
||||
|
||||
// Update 更新技能定义。
|
||||
func (r SkillDefinitionDAO) Update(s *skillmodel.SkillDefinition) bool {
|
||||
return r.Save(s)
|
||||
}
|
||||
|
||||
// Delete 硬删除技能定义。
|
||||
//
|
||||
// 表上没有软删字段,删了就是删了 —— 跟「停用」(state=inactive)是两回事:
|
||||
// 停用还留着记录,挂在它名下的技能引用仍能解释。
|
||||
func (r SkillDefinitionDAO) Delete(s *skillmodel.SkillDefinition) bool {
|
||||
return r.QueryBuilder.Delete(s)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
|
||||
)
|
||||
|
||||
// Specialist 数字员工专员目录仓库。
|
||||
//
|
||||
// 模型定义在 specialists 领域包内(internal/specialists/model),取数统一走这里——
|
||||
// 「按 key 找专员」原先在 4 个文件里各写了一遍,散着改迟早分叉。
|
||||
type SpecialistDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByKey 按 key 取专员,**不限 state**。
|
||||
//
|
||||
// 不过滤 state 是有意的:调用方对「停用的专员算不算数」口径不同——
|
||||
// 建任务时要能查到(任务挂在已下线的专员上仍要能解释),
|
||||
// 而对话取 prompt 时要拒绝 inactive。把口径留在调用方,这里只负责取数。
|
||||
func (r SpecialistDAO) GetByKey(key string) (specialistmodel.Specialist, bool) {
|
||||
var s specialistmodel.Specialist
|
||||
if r.Type(&s).Where("key = ?", key).First(&s) {
|
||||
return s, true
|
||||
}
|
||||
return specialistmodel.Specialist{}, false
|
||||
}
|
||||
|
||||
// GetByID 按 ID 取专员。
|
||||
func (r SpecialistDAO) GetByID(id uint) (specialistmodel.Specialist, bool) {
|
||||
var s specialistmodel.Specialist
|
||||
if r.Type(&s).Where("id = ?", id).First(&s) {
|
||||
return s, true
|
||||
}
|
||||
return specialistmodel.Specialist{}, false
|
||||
}
|
||||
|
||||
// Query 返回专员表的查询构建器(已 Type 好)。
|
||||
//
|
||||
// 给「带自己那套 state 策略」的调用方用——比如按 key 取且非管理员只认 active、
|
||||
// 或者按 state 分档计数。这类策略是各接口自己的口径,不在这里替它们定。
|
||||
func (r SpecialistDAO) Query() *QueryBuilder {
|
||||
return r.QueryBuilder.Query().Type(&specialistmodel.Specialist{})
|
||||
}
|
||||
|
||||
// List 专员目录列表(sort_order ASC, id ASC)。
|
||||
//
|
||||
// state 的档位是接口契约,调用方直接透传 query 参数:
|
||||
// - ""(默认):仅 active —— 员工浏览视角
|
||||
// - "all":管理员看全部状态,非管理员仍只看 active
|
||||
// - "system":仅 system(通用助手那条内置记录)
|
||||
// - 其它:按该状态精确匹配
|
||||
//
|
||||
// 除显式要 system 外,一律排除 system 记录——它是内置的通用助手,
|
||||
// 不该混进专员目录里让人当成一个可选的专员。
|
||||
func (r SpecialistDAO) List(tier, marketTag, state string, isAdmin bool) []specialistmodel.Specialist {
|
||||
q := r.Type(&specialistmodel.Specialist{})
|
||||
if tier != "" {
|
||||
q = q.Where("tier = ?", tier)
|
||||
}
|
||||
if marketTag != "" {
|
||||
q = q.Where("market_tag = ?", marketTag)
|
||||
}
|
||||
switch state {
|
||||
case "":
|
||||
q = q.Where("state = ?", "active")
|
||||
case "all":
|
||||
if !isAdmin {
|
||||
q = q.Where("state = ?", "active")
|
||||
}
|
||||
case "system":
|
||||
q = q.Where("state = ?", "system")
|
||||
default:
|
||||
q = q.Where("state = ?", state)
|
||||
}
|
||||
if state != "system" {
|
||||
q = q.Where("state <> ?", "system")
|
||||
}
|
||||
var items []specialistmodel.Specialist
|
||||
if q.Order("sort_order ASC, id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByKey 按 key 统计(唯一性检查)。excludeID 用于更新时排除自身,nil 表示不排除。
|
||||
func (r SpecialistDAO) CountByKey(key string, excludeID *uint) int64 {
|
||||
q := r.Inner().Model(&specialistmodel.Specialist{}).Where("key = ?", key)
|
||||
if excludeID != nil {
|
||||
q = q.Where("id <> ?", *excludeID)
|
||||
}
|
||||
var c int64
|
||||
q.Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// Insert 新建专员。
|
||||
func (r SpecialistDAO) Insert(s *specialistmodel.Specialist) bool {
|
||||
return r.QueryBuilder.Insert(s)
|
||||
}
|
||||
|
||||
// Update 更新专员。
|
||||
func (r SpecialistDAO) Update(s *specialistmodel.Specialist) bool {
|
||||
return r.Save(s)
|
||||
}
|
||||
|
||||
// Delete 硬删除专员。
|
||||
//
|
||||
// 表上没有软删字段,删了就是删了——跟「停用」(state=inactive)是两回事:
|
||||
// 停用还留着记录、还能查到,删除会让挂在它名下的历史任务失去解释依据。
|
||||
func (r SpecialistDAO) Delete(id uint) bool {
|
||||
return r.Type(&specialistmodel.Specialist{}).Where("id = ?", id).Delete(&specialistmodel.Specialist{})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// StudyNote 学习笔记仓库(员工私人笔记,按 user_id 隔离)。
|
||||
type StudyNoteDAO struct{ *QueryBuilder }
|
||||
|
||||
// ListByUser 某用户的笔记,最近改过的在前。
|
||||
//
|
||||
// itemType 为空表示不按内容类型过滤;itemID 为 nil 表示不按内容 ID 过滤
|
||||
// (指针而非 0 值,是因为 company 类笔记的 item_id 本来就固定是 0)。
|
||||
func (r StudyNoteDAO) ListByUser(userID uint, itemType string, itemID *uint) []model.StudyNote {
|
||||
q := r.Type(&model.StudyNote{}).Where("user_id = ?", userID)
|
||||
if itemType != "" {
|
||||
q = q.Where("item_type = ?", itemType)
|
||||
}
|
||||
if itemID != nil {
|
||||
q = q.Where("item_id = ?", *itemID)
|
||||
}
|
||||
var items []model.StudyNote
|
||||
if q.Order("updated_at DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 取笔记。**不在这里判归属**——越权检查留在 handler,
|
||||
// 那里能把「不存在」和「不是你的」分别回成 404 / 403。
|
||||
func (r StudyNoteDAO) GetByID(id uint) (model.StudyNote, bool) {
|
||||
var n model.StudyNote
|
||||
if r.Type(&n).Where("id = ?", id).First(&n) {
|
||||
return n, true
|
||||
}
|
||||
return model.StudyNote{}, false
|
||||
}
|
||||
|
||||
// Insert 新建笔记。
|
||||
func (r StudyNoteDAO) Insert(n *model.StudyNote) bool {
|
||||
return r.QueryBuilder.Insert(n)
|
||||
}
|
||||
|
||||
// Update 更新笔记。
|
||||
func (r StudyNoteDAO) Update(n *model.StudyNote) bool {
|
||||
return r.Save(n)
|
||||
}
|
||||
|
||||
// Delete 删除笔记(硬删,表上没有软删字段)。
|
||||
func (r StudyNoteDAO) Delete(n *model.StudyNote) bool {
|
||||
return r.QueryBuilder.Delete(n)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// SystemConfig 系统参数仓库。
|
||||
type SystemConfigDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByKey 按 key 获取参数值。
|
||||
func (r SystemConfigDAO) GetByKey(key string) string {
|
||||
var c model.SystemConfig
|
||||
if r.Type(&c).Where("config_key = ?", key).First(&c) {
|
||||
return c.ConfigValue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get 按 key 获取完整记录。
|
||||
func (r SystemConfigDAO) Get(key string) (model.SystemConfig, bool) {
|
||||
var c model.SystemConfig
|
||||
if r.Type(&c).Where("config_key = ?", key).First(&c) {
|
||||
return c, true
|
||||
}
|
||||
return model.SystemConfig{}, false
|
||||
}
|
||||
|
||||
// SetOrUpdate 设置或更新参数。
|
||||
func (r SystemConfigDAO) SetOrUpdate(key, value string) bool {
|
||||
existing, found := r.Get(key)
|
||||
if !found {
|
||||
return r.Insert(&model.SystemConfig{ConfigKey: key, ConfigValue: value})
|
||||
}
|
||||
existing.ConfigValue = value
|
||||
return r.Save(&existing)
|
||||
}
|
||||
|
||||
// List 获取所有参数。
|
||||
func (r SystemConfigDAO) List() []model.SystemConfig {
|
||||
var items []model.SystemConfig
|
||||
if r.Type(&items).Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkUpsert 批量插入或更新。
|
||||
func (r SystemConfigDAO) BulkUpsert(items []model.SystemConfig) int {
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if r.SetOrUpdate(item.ConfigKey, item.ConfigValue) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// TaskArtifact 专员交付物仓库。
|
||||
type TaskArtifactDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByID 按 ID 取交付物。
|
||||
func (r TaskArtifactDAO) GetByID(id uint) (model.TaskArtifact, bool) {
|
||||
var a model.TaskArtifact
|
||||
if r.Type(&a).Where("id = ?", id).First(&a) {
|
||||
return a, true
|
||||
}
|
||||
return model.TaskArtifact{}, false
|
||||
}
|
||||
|
||||
// ListByTask 某任务的全部交付物(最近产出的在前)。
|
||||
func (r TaskArtifactDAO) ListByTask(taskID uint) []model.TaskArtifact {
|
||||
var items []model.TaskArtifact
|
||||
if r.Type(&items).Where("task_id = ?", taskID).
|
||||
Order("created_at DESC, id DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByTask 删掉某任务名下的全部交付物,条数一并返回。
|
||||
// 与 TaskRunDAO.DeleteByTask 配对使用(重置任务配置时两样一起清)。
|
||||
func (r TaskArtifactDAO) DeleteByTask(taskID uint) (int64, bool) {
|
||||
res := r.Inner().Where("task_id = ?", taskID).Delete(&model.TaskArtifact{})
|
||||
return res.RowsAffected, res.Error == nil
|
||||
}
|
||||
|
||||
// Insert 新建交付物。
|
||||
func (r TaskArtifactDAO) Insert(a *model.TaskArtifact) bool {
|
||||
return r.QueryBuilder.Insert(a)
|
||||
}
|
||||
|
||||
// Update 更新交付物(状态流转走这里)。
|
||||
func (r TaskArtifactDAO) Update(a *model.TaskArtifact) bool {
|
||||
return r.Save(a)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// TaskRecord 专员事项/任务仓库。
|
||||
//
|
||||
// 任务是这个聚合的根:交付物(task_artifact)与运行记录(task_run)都挂在它下面,
|
||||
// 所以级联删除也放在这里,而不是让 handler 去协调三个仓库。
|
||||
type TaskRecordDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByID 按 ID 取任务。
|
||||
func (r TaskRecordDAO) GetByID(id uint) (model.TaskRecord, bool) {
|
||||
var t model.TaskRecord
|
||||
if r.Type(&t).Where("id = ?", id).First(&t) {
|
||||
return t, true
|
||||
}
|
||||
return model.TaskRecord{}, false
|
||||
}
|
||||
|
||||
// GetByIDForOwners 按 ID 取任务,且必须归属于 owners 里的一员。
|
||||
//
|
||||
// owners 为空时查不到任何东西(而不是查到全部):归属标识缺失时必须退化成
|
||||
// 「什么都看不到」,绝不能反过来退化成「看所有人的」。
|
||||
func (r TaskRecordDAO) GetByIDForOwners(id uint, owners []string) (model.TaskRecord, bool) {
|
||||
var t model.TaskRecord
|
||||
if ownerScope(r.Type(&t), owners).Where("id = ?", id).First(&t) {
|
||||
return t, true
|
||||
}
|
||||
return model.TaskRecord{}, false
|
||||
}
|
||||
|
||||
// ListBySpecialistKey 某专员名下的全部任务(最近更新的在前)。
|
||||
func (r TaskRecordDAO) ListBySpecialistKey(specialistKey string) []model.TaskRecord {
|
||||
var items []model.TaskRecord
|
||||
if r.Type(&items).Where("specialist_key = ?", specialistKey).
|
||||
Order("updated_at DESC, id DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByOwners 「我的任务」:置顶的排前面,其余按最近动过的排。
|
||||
// limit <= 0 表示不设上限。
|
||||
func (r TaskRecordDAO) ListByOwners(owners []string, limit int) []model.TaskRecord {
|
||||
q := ownerScope(r.Type(&model.TaskRecord{}), owners)
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
var items []model.TaskRecord
|
||||
if q.Order("pinned DESC, updated_at DESC, id DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByProject 项目下的任务(最近更新的在前)。limit <= 0 表示不设上限。
|
||||
func (r TaskRecordDAO) ListByProject(projectID uint, limit int) []model.TaskRecord {
|
||||
q := r.Type(&model.TaskRecord{}).Where("project_id = ?", projectID)
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
var items []model.TaskRecord
|
||||
if q.Order("updated_at DESC, id DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountBySpecialistKey 统计某专员名下的任务数(首次进入时判断要不要铺底任务)。
|
||||
func (r TaskRecordDAO) CountBySpecialistKey(specialistKey string) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.TaskRecord{}).Where("specialist_key = ?", specialistKey).Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// Insert 新建任务。
|
||||
func (r TaskRecordDAO) Insert(t *model.TaskRecord) bool {
|
||||
return r.QueryBuilder.Insert(t)
|
||||
}
|
||||
|
||||
// Update 更新任务。
|
||||
func (r TaskRecordDAO) Update(t *model.TaskRecord) bool {
|
||||
return r.Save(t)
|
||||
}
|
||||
|
||||
// Delete 删除单条任务(不动它的交付物与运行记录,级联请用 DeleteCascade)。
|
||||
func (r TaskRecordDAO) Delete(t *model.TaskRecord) bool {
|
||||
return r.QueryBuilder.Delete(t)
|
||||
}
|
||||
|
||||
// ClearProject 把项目下的任务全部解除归属(project_id 置空)。
|
||||
//
|
||||
// 任务是「做过的事」,删一个分组不该把它一起抹掉,所以只解除归属、不删记录。
|
||||
func (r TaskRecordDAO) ClearProject(projectID uint) bool {
|
||||
return r.Type(&model.TaskRecord{}).Where("project_id = ?", projectID).
|
||||
UpdateColumn("project_id", nil)
|
||||
}
|
||||
|
||||
// DeleteCascade 删任务,连同它的交付物与运行记录。
|
||||
//
|
||||
// 三张表必须一起成功或一起失败——留下没有任务的交付物,详情页就再也点不进去了。
|
||||
func (r TaskRecordDAO) DeleteCascade(id uint) bool {
|
||||
err := r.Inner().Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("task_id = ?", id).Delete(&model.TaskArtifact{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("task_id = ?", id).Delete(&model.TaskRun{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.TaskRecord{}, id).Error
|
||||
})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ownerScope 给查询套上归属范围。owners 为空时套一个恒假条件(空集,不是全表)。
|
||||
func ownerScope(q *QueryBuilder, owners []string) *QueryBuilder {
|
||||
if len(owners) == 0 {
|
||||
return q.Where("1 = 0")
|
||||
}
|
||||
return q.Where("owner IN ?", owners)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// TaskRun 专员动作运行记录仓库。
|
||||
//
|
||||
// 运行记录只增不改:一次动作一条,是任务详情的「时间线/回放」底稿。
|
||||
type TaskRunDAO struct{ *QueryBuilder }
|
||||
|
||||
// GetByID 按 ID 取运行记录。
|
||||
func (r TaskRunDAO) GetByID(id uint) (model.TaskRun, bool) {
|
||||
var run model.TaskRun
|
||||
if r.Type(&run).Where("id = ?", id).First(&run) {
|
||||
return run, true
|
||||
}
|
||||
return model.TaskRun{}, false
|
||||
}
|
||||
|
||||
// ListByTask 某任务的全部运行记录(最近开始的在前)。
|
||||
func (r TaskRunDAO) ListByTask(taskID uint) []model.TaskRun {
|
||||
var items []model.TaskRun
|
||||
if r.Type(&items).Where("task_id = ?", taskID).
|
||||
Order("started_at DESC, id DESC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByTask 删掉某任务名下的全部运行记录,条数一并返回。
|
||||
//
|
||||
// 用于「重置任务配置」:旧配置下的运行记录留着会把时间线和产物对错,
|
||||
// 所以整批清掉重来。这里不做级联,产物由 TaskArtifactDAO.DeleteByTask 单独清 ——
|
||||
// 调用方要的是「两样都清干净」,出错了才分得清是哪一边没清成。
|
||||
func (r TaskRunDAO) DeleteByTask(taskID uint) (int64, bool) {
|
||||
res := r.Inner().Where("task_id = ?", taskID).Delete(&model.TaskRun{})
|
||||
return res.RowsAffected, res.Error == nil
|
||||
}
|
||||
|
||||
// Insert 追加一条运行记录。
|
||||
func (r TaskRunDAO) Insert(run *model.TaskRun) bool {
|
||||
return r.QueryBuilder.Insert(run)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
)
|
||||
|
||||
// User 用户仓库。
|
||||
type UserDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 获取用户列表(分页)。
|
||||
func (r UserDAO) List(page, pageSize int, role, status string) []model.User {
|
||||
q := r.Type(&model.User{})
|
||||
if role != "" {
|
||||
q = q.Where("role = ?", role)
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
var items []model.User
|
||||
if q.Order("id ASC").Limit(pageSize).Offset(offset).Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Total 统计用户总数。
|
||||
func (r UserDAO) Total(role, status string) int64 {
|
||||
q := r.Inner().Model(&model.User{})
|
||||
if role != "" {
|
||||
q = q.Where("role = ?", role)
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var c int64
|
||||
q.Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// ListEmployees 取 role=employee 的用户(id 升序)。
|
||||
//
|
||||
// status 语义刻意写成显式档位,避免与列表接口的「空=active」约定混淆:
|
||||
//
|
||||
// "active" → 仅在职(部门成员数、部门学情统计用这份口径)
|
||||
// "" → 不限状态(管理端全员视图,含已停用)
|
||||
// 其它 → 按该状态过滤
|
||||
func (r UserDAO) ListEmployees(status string) []model.User {
|
||||
q := r.Type(&model.User{}).Where("role = ?", "employee")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var items []model.User
|
||||
if q.Order("id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountActiveByDepartment 统计归属某部门的在职员工数(按 user.department 字符串匹配)。
|
||||
func (r UserDAO) CountActiveByDepartment(name string) int64 {
|
||||
var c int64
|
||||
r.Inner().Model(&model.User{}).
|
||||
Where("department = ? AND status = ?", name, "active").Count(&c)
|
||||
return c
|
||||
}
|
||||
|
||||
// RenameDepartment 部门改名后同步员工归属字符串。
|
||||
//
|
||||
// 部门是字典表、用户以字符串归属,改字典名必须回写 user.department,
|
||||
// 否则按部门统计与展示会当场对不上。
|
||||
// 用 Updates(而非 UpdateColumn)是为了让 GORM 照常带上 updated_at,
|
||||
// 与原实现 `Model(&User{}).Where(...).Update("department", ...)` 的行为一致。
|
||||
func (r UserDAO) RenameDepartment(oldName, newName string) bool {
|
||||
return r.Type(&model.User{}).Where("department = ?", oldName).
|
||||
Updates(map[string]any{"department": newName})
|
||||
}
|
||||
|
||||
// GetByID 按 ID 获取。
|
||||
func (r UserDAO) GetByID(id uint) (model.User, bool) {
|
||||
var u model.User
|
||||
if r.Type(&u).Where("id = ?", id).First(&u) {
|
||||
return u, true
|
||||
}
|
||||
return model.User{}, false
|
||||
}
|
||||
|
||||
// GetByUsername 按用户名获取。
|
||||
func (r UserDAO) GetByUsername(username string) (model.User, bool) {
|
||||
var u model.User
|
||||
if r.Type(&u).Where("username = ?", username).First(&u) {
|
||||
return u, true
|
||||
}
|
||||
return model.User{}, false
|
||||
}
|
||||
|
||||
// GetByEmail 按邮箱获取。
|
||||
func (r UserDAO) GetByEmail(email string) (model.User, bool) {
|
||||
var u model.User
|
||||
if r.Type(&u).Where("email = ?", email).First(&u) {
|
||||
return u, true
|
||||
}
|
||||
return model.User{}, false
|
||||
}
|
||||
|
||||
// Insert 创建用户。
|
||||
func (r UserDAO) Insert(u *model.User) bool {
|
||||
return r.QueryBuilder.Insert(u)
|
||||
}
|
||||
|
||||
// Update 更新用户。
|
||||
func (r UserDAO) Update(u *model.User) bool {
|
||||
return r.Save(u)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新状态。
|
||||
func (r UserDAO) UpdateStatus(id uint, status string) bool {
|
||||
return r.Type(&model.User{}).Where("id = ?", id).UpdateColumn("status", status)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
xappmodel "eai_agentplatform/backend/internal/xapps/model"
|
||||
)
|
||||
|
||||
// UserXAppCenter 用户自己的应用中心状态仓库(收藏 / 最近使用 / 自定义应用,每人一份)。
|
||||
//
|
||||
// 这里的方法签名**有意不跟**其它仓库的 bool 惯例:FindByUser 返回 error。
|
||||
// 原因是本仓库的「没找到」是一个有意义的第三态 —— 它表示「该用户还没配过,
|
||||
// 调用方要新建一份」。若压成 bool,一旦读取真出错(SQLite 本地锁等待是常事)
|
||||
// 就会被当成「没配过」,转而写一份空白配置,把用户已有的收藏和最近使用抹掉。
|
||||
// user_id 上有唯一索引,这种误写多半会撞唯一键而失败,但那是运气不是设计。
|
||||
type UserXAppCenterDAO struct{ *QueryBuilder }
|
||||
|
||||
// FindByUser 取某用户的应用中心配置。三种结果:
|
||||
// - row != nil 找到
|
||||
// - row == nil, err == nil 该用户还没有配置,调用方应新建(不是错误)
|
||||
// - err != nil 读取出错,调用方应报错,**不要**当成「没有配置」
|
||||
func (r UserXAppCenterDAO) FindByUser(userID uint) (*xappmodel.UserXAppCenter, error) {
|
||||
var row xappmodel.UserXAppCenter
|
||||
err := r.Inner().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
|
||||
}
|
||||
|
||||
// Save 保存应用中心配置(新建或更新,由 row.ID 是否为零值决定)。
|
||||
func (r UserXAppCenterDAO) Save(row *xappmodel.UserXAppCenter) bool {
|
||||
return r.QueryBuilder.Save(row)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dal
|
||||
|
||||
import (
|
||||
xappmodel "eai_agentplatform/backend/internal/xapps/model"
|
||||
)
|
||||
|
||||
// XAppDefinition 应用定义仓库。
|
||||
//
|
||||
// 应用(App)是对象层三类一级对象之一,定位是「长程任务运行壳」。
|
||||
// 本仓库只管应用目录本身的取数;用户自己的应用中心状态见 UserXAppCenterDAO。
|
||||
type XAppDefinitionDAO struct{ *QueryBuilder }
|
||||
|
||||
// List 应用定义列表(sort_order ASC, id ASC)。
|
||||
//
|
||||
// state 与 exposedToUser 的约定同 SkillDefinitionDAO.List:
|
||||
// state 的默认值由 handler 定,exposedToUser 用三态指针区分「不传」与「传 false」。
|
||||
func (r XAppDefinitionDAO) List(state string, exposedToUser *bool) []xappmodel.XAppDefinition {
|
||||
q := r.Type(&xappmodel.XAppDefinition{})
|
||||
if state != "" {
|
||||
q = q.Where("state = ?", state)
|
||||
}
|
||||
if exposedToUser != nil {
|
||||
q = q.Where("exposed_to_user = ?", *exposedToUser)
|
||||
}
|
||||
var items []xappmodel.XAppDefinition
|
||||
if q.Order("sort_order ASC, id ASC").Find(&items) {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 取。
|
||||
func (r XAppDefinitionDAO) GetByID(id uint) (xappmodel.XAppDefinition, bool) {
|
||||
var a xappmodel.XAppDefinition
|
||||
if r.Type(&a).Where("id = ?", id).First(&a) {
|
||||
return a, true
|
||||
}
|
||||
return xappmodel.XAppDefinition{}, false
|
||||
}
|
||||
|
||||
// GetByKey 按 key 取(key 上有唯一索引)。
|
||||
func (r XAppDefinitionDAO) GetByKey(key string) (xappmodel.XAppDefinition, bool) {
|
||||
var a xappmodel.XAppDefinition
|
||||
if r.Type(&a).Where("key = ?", key).First(&a) {
|
||||
return a, true
|
||||
}
|
||||
return xappmodel.XAppDefinition{}, false
|
||||
}
|
||||
|
||||
// Insert 新建应用定义。
|
||||
func (r XAppDefinitionDAO) Insert(a *xappmodel.XAppDefinition) bool {
|
||||
return r.QueryBuilder.Insert(a)
|
||||
}
|
||||
|
||||
// Update 更新应用定义。
|
||||
func (r XAppDefinitionDAO) Update(a *xappmodel.XAppDefinition) bool {
|
||||
return r.Save(a)
|
||||
}
|
||||
|
||||
// Delete 硬删除应用定义。
|
||||
func (r XAppDefinitionDAO) Delete(a *xappmodel.XAppDefinition) bool {
|
||||
return r.QueryBuilder.Delete(a)
|
||||
}
|
||||
Reference in New Issue
Block a user