Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/api/knowledge.go
T
eaiadminandClaude Code 19cf6fb5f2 refactor: 后端仓库层收口(A3:考试/部门/学习/证书/档案/公司介绍)
把 A 档剩余对象的 api 裸查询全部收进仓库,api 层裸 store.DB 从 182 降到 99,
剩下的全是 B 档(任务/项目/笔记等尚无仓库的对象)与 C 档(报表聚合查询)。

按对象补齐的仓库方法:
- QuestionRepo:List 重写(status 档位改为显式 all/空/具体值)、
  ListByIDs(判分不过滤 status)、ListActiveByIDs(下发剔除停用)、
  ActivePool(抽题口径,主流程与蓝图共用)、DomainMap(能力雷达反查域)
- ExamPaperRepo.List;ExamRecordRepo.ListByUserChronological(趋势图正序)
- DepartmentRepo.ListByStatus / CountByName
- UserRepo.ListEmployees / CountActiveByDepartment / RenameDepartment
- LearningProgressRepo.ListAll;CertificateRepo.ListAll / GetByExamRecord
- MediaFileRepo.ListApprovedByBindType

顺带修掉两处隐患:
- CertificateRepo.GetByUserAndExam 按不存在的 exam_id 列查,一调即 SQL 报错,
  换成按 exam_record_id 的 GetByExamRecord(颁发幂等本来就该按考试记录)
- exam.go 与 system.go 各声明了一个 ExamRecordRepo 变量,同一个仓库两份变量
  会导致测试覆写时行为分叉,统一为一个 examRecordRepo

考证来源(趋势图正序 vs 列表页倒序)与抽题口径(岗位蓝图/岗位知识映射两条路径)
各自抽成单一出处,避免两处手写漂移。聚合与百分比计算仍留在 handler,未搬进仓库。

验证:tmp 验证程序走真实路由 + 真实 HTTP,对 DB 副本跑 111 项断言全绿
(覆盖停用题仍可判分、错题重练剔除停用题、趋势正序、改名同步 user.department
且 updated_at 仍刷新、未通过的正式考不发证书、公司介绍只出 approved 素材等)。
另对其中 8 条关键语义做了变异测试:逐条注入反向实现,确认断言确实会失败,
并因此发现并修掉验证程序自身一处漏洞(写语句的约束错误只在 rows.Err() 浮出,
原先未检查,导致一条断言实为空断言)。

原始 data/eai_agentplatform.db 全程未触碰,md5 复核一致。

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

399 lines
11 KiB
Go

package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/repository"
"eai_agentplatform/backend/internal/web"
)
// sourceRepo 知识源仓库(便于测试时覆写),包内共享。
var sourceRepo repository.KnowledgeSourceRepo
func init() {
sourceRepo = repository.KnowledgeSourceRepo{}
}
// ============ 扫描 ============
// KnowledgeScan POST /api/knowledge/scan —— 扫描 knowledge_source 目录建 pending 记录
func KnowledgeScan(c *gin.Context) {
entries, err := os.ReadDir(Cfg.KnowledgeSourceDir)
if err != nil {
web.Fail(c, web.NewBadRequest("知识源目录不存在:"+Cfg.KnowledgeSourceDir))
return
}
results := make([]gin.H, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
continue
}
if strings.HasPrefix(strings.ToUpper(e.Name()), "README") {
continue
}
full := filepath.Join(Cfg.KnowledgeSourceDir, e.Name())
data, err := os.ReadFile(full)
if err != nil {
continue
}
fm := parseFrontMatter(string(data))
if fm["category"] == "" || fm["version"] == "" {
continue // 非知识源文档,跳过
}
if existing, found := sourceRepo.GetByFilePath(e.Name()); found {
results = append(results, gin.H{"file_path": e.Name(), "status": "skipped", "title": existing.Title})
continue
}
src := model.KnowledgeSource{
Title: parseTitle(string(data)),
FilePath: e.Name(),
Category: fm["category"],
Domain: orDefault(fm["domain"], "product"),
SourceVersion: fm["version"],
AuditStatus: "pending",
KnowledgeSpaceKey: ensureKnowledgeSpaceKeyOrDefault(orDefault(fm["knowledge_space_key"], inferKnowledgeSpaceKey(parseTitle(string(data)), fm["domain"], fm["category"]+" "+e.Name()))),
}
if !sourceRepo.Insert(&src) {
results = append(results, gin.H{"file_path": e.Name(), "status": "error", "title": src.Title})
continue
}
results = append(results, gin.H{"file_path": e.Name(), "status": "created", "title": src.Title, "knowledge_space_key": src.KnowledgeSpaceKey})
}
web.OK(c, gin.H{"results": results})
}
// KnowledgeAuditList GET /api/knowledge/audit-list?status=&page=&size=
func KnowledgeAuditList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
total, items := sourceRepo.ListForAudit(
c.Query("status"), sanitizeSpaceKey(c.Query("knowledge_space_key")), page, size)
web.OK(c, gin.H{"total": total, "items": items})
}
// KnowledgeAudit POST /api/knowledge/audit/{source_id} —— 审批(approve→摄入 / reject→驳回)
func KnowledgeAudit(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "source_id")
if !ok {
return
}
src, found := sourceRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("知识源不存在"))
return
}
var req struct {
Action string `json:"action"`
RejectReason string `json:"reject_reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
now := time.Now()
auditBy := u.ID
switch req.Action {
case "approve":
if src.Ingested {
web.OK(c, gin.H{"status": "approved", "source_id": src.ID, "products": 0, "chunks": 0, "questions": 0, "already_ingested": true})
return
}
counts, err := ingestSource(&src)
if err != nil {
web.Fail(c, web.NewBadRequest("摄入失败:"+err.Error()))
return
}
src.AuditStatus = "approved"
src.AuditBy = &auditBy
src.AuditAt = &now
src.RejectReason = ""
src.Ingested = true
if !sourceRepo.Update(&src) {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
triggerKnowledgeIndexRebuild()
web.OK(c, gin.H{
"status": "approved", "source_id": src.ID,
"products": counts[0], "chunks": counts[1], "questions": counts[2],
})
case "reject":
if strings.TrimSpace(req.RejectReason) == "" {
web.Fail(c, web.NewBadRequest("驳回理由必填"))
return
}
src.AuditStatus = "rejected"
src.RejectReason = req.RejectReason
src.AuditBy = &auditBy
src.AuditAt = &now
if !sourceRepo.Update(&src) {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
web.OK(c, gin.H{"status": "rejected", "source_id": src.ID})
default:
web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject"))
}
}
// KnowledgeStatus GET /api/knowledge/status/{source_id}
func KnowledgeStatus(c *gin.Context) {
id, ok := parseID(c, "source_id")
if !ok {
return
}
src, found := sourceRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("知识源不存在"))
return
}
web.OK(c, gin.H{
"audit_status": src.AuditStatus,
"ingested": src.Ingested,
"reject_reason": src.RejectReason,
"knowledge_space_key": resolveKnowledgeSourceSpaceKey(src),
})
}
// ============ md 解析 ============
func parseFrontMatter(content string) map[string]string {
m := map[string]string{}
parts := strings.SplitN(content, "---", 3)
if len(parts) < 3 {
return m
}
for _, line := range strings.Split(parts[1], "\n") {
line = strings.TrimSpace(line)
if line == "" || !strings.Contains(line, ":") {
continue
}
kv := strings.SplitN(line, ":", 2)
m[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
return m
}
func parseTitle(content string) string {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
}
return ""
}
type mdBlock struct {
title string
body string
}
func splitSections(content string) map[string]string {
sections := map[string]string{}
lines := strings.Split(content, "\n")
current := ""
var buf strings.Builder
for _, line := range lines {
if strings.HasPrefix(line, "## ") {
if current != "" {
sections[current] = buf.String()
}
current = strings.TrimSpace(strings.TrimPrefix(line, "## "))
buf.Reset()
} else {
buf.WriteString(line + "\n")
}
}
if current != "" {
sections[current] = buf.String()
}
return sections
}
func splitBlocks(body string) []mdBlock {
var blocks []mdBlock
current := ""
var buf strings.Builder
flush := func() {
if current != "" || strings.TrimSpace(buf.String()) != "" {
blocks = append(blocks, mdBlock{current, buf.String()})
}
}
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "### ") {
flush()
current = strings.TrimSpace(strings.TrimPrefix(line, "### "))
buf.Reset()
} else {
buf.WriteString(line + "\n")
}
}
flush()
return blocks
}
func parseKV(body string) map[string]string {
m := map[string]string{}
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "- ") {
continue
}
line = strings.TrimPrefix(line, "- ")
if i := strings.Index(line, ":"); i >= 0 {
m[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
}
}
return m
}
// indexKey 选项索引 → 键(0→A, 1→B ...)
func indexKey(i int) string {
if i < 0 || i > 25 {
return "?"
}
return string(rune('A' + i))
}
func convertOptions(raw []string) []Option {
opts := make([]Option, len(raw))
for i, text := range raw {
opts[i] = Option{Key: indexKey(i), Text: text}
}
return opts
}
func convertAnswer(raw []any) []string {
out := make([]string, 0, len(raw))
for _, a := range raw {
switch v := a.(type) {
case bool:
out = append(out, strconv.FormatBool(v))
case float64:
out = append(out, indexKey(int(v)))
case string:
out = append(out, v)
}
}
return out
}
// ============ 摄入 ============
// ingestSource 解析 md → product / knowledge_chunk / question,返回三表计数
//
// 口径提示:counts 记的是**从 md 解析出的条目数**,不是落库成功数 ——
// 单条写入失败不中断、也不扣减计数(沿用原有行为,未改)。
func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
var counts [3]int
full := filepath.Join(Cfg.KnowledgeSourceDir, src.FilePath)
data, err := os.ReadFile(full)
if err != nil {
return counts, fmt.Errorf("读取文件失败")
}
content := string(data)
fm := parseFrontMatter(content)
cat := orDefault(src.Category, fm["category"])
sections := splitSections(content)
// 1. 产品
for _, b := range splitBlocks(sections["结构化产品数据"]) {
kv := parseKV(b.body)
if kv["code"] == "" || kv["name"] == "" {
continue
}
p := model.Product{
Code: kv["code"],
Name: kv["name"],
Category: orDefault(kv["category"], cat),
Tags: kv["tags"],
Description: kv["description"],
Pricing: kv["pricing"],
CommissionRecommend: kv["commission_recommend"],
CommissionNegotiate: kv["commission_negotiate"],
PublicCourseBonus: kv["public_course_bonus"],
VersionRisk: kv["version_risk"],
ReportRules: kv["report_rules"],
Status: "active",
}
if existing, found := productRepo.GetByCode(p.Code); found {
p.ID = existing.ID
p.CreatedAt = existing.CreatedAt // Save 整字段覆盖,不回填会把创建时间写成零值
productRepo.Update(&p)
} else {
productRepo.Insert(&p)
}
counts[0]++
}
// 2. AI 检索知识 → knowledge_chunk
for i, b := range splitBlocks(sections["AI 检索知识"]) {
text := strings.TrimSpace(b.body)
if text == "" {
continue
}
chunkRepo.Insert(&model.KnowledgeChunk{
KnowledgeSourceID: &src.ID,
SourceType: "md",
SourceID: strconv.FormatUint(uint64(src.ID), 10),
KnowledgeSpaceKey: resolveKnowledgeSourceSpaceKey(*src),
ChunkIndex: i,
Content: text,
})
counts[1]++
}
// 3. 考试题目 → question
for _, b := range splitBlocks(sections["考试题目"]) {
kv := parseKV(b.body)
if kv["type"] == "" || kv["stem"] == "" {
continue
}
var optsRaw []string
_ = json.Unmarshal([]byte(kv["options"]), &optsRaw)
var ansRaw []any
_ = json.Unmarshal([]byte(kv["answer"]), &ansRaw)
optsJSON, _ := json.Marshal(convertOptions(optsRaw))
ansJSON, _ := json.Marshal(convertAnswer(ansRaw))
q := model.Question{
Domain: orDefault(kv["domain"], src.Domain),
Type: kv["type"],
Stem: kv["stem"],
Options: string(optsJSON),
Answer: string(ansJSON),
Explanation: kv["explanation"],
Status: "active",
}
questionRepo.Insert(&q)
counts[2]++
}
return counts, nil
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}