init: 数字员工平台初始代码

包含前端(Vue3 + VueFlow 画布)、后端(Go)、文档体系。
- 工作台画布:节点拖放、连线模式、右键菜单、AI 助手
- 后端:连接器 API、专员种子数据
- 导航:左侧导航、工坊、市场、控制台
This commit is contained in:
eaiadmin
2026-08-18 20:19:58 +08:00
commit 4e8817d768
239 changed files with 48631 additions and 0 deletions
@@ -0,0 +1,63 @@
package api
import (
"encoding/json"
"io"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/web"
)
// ──────────────────────────────────────────────
// AI 配置管理(管理员)—— 读/写 ai_config.json + 热重载 + 密钥状态
// ──────────────────────────────────────────────
// GetAIConfig GET /api/ai/config —— 读完整 ai_config.json(返回 JSON 对象)
func GetAIConfig(c *gin.Context) {
raw, err := config.GetAIConfigRaw()
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("读取 ai_config.json 失败: "+err.Error()))
return
}
var obj any
if err := json.Unmarshal(raw, &obj); err != nil {
web.Fail(c, web.NewBadRequest("ai_config.json 解析失败: "+err.Error()))
return
}
web.OK(c, obj)
}
// PutAIConfig PUT /api/ai/config —— 校验并写回 ai_config.json(原子写 + 清缓存热生效)
func PutAIConfig(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
web.Fail(c, web.NewBadRequest("读取请求体失败"))
return
}
if len(raw) == 0 {
web.Fail(c, web.NewBadRequest("请求体为空"))
return
}
if err := config.SaveAIConfig(raw); err != nil {
web.Fail(c, web.NewBadRequest(err.Error()))
return
}
web.OK(c, gin.H{"saved": true})
}
// ReloadAIConfig POST /api/ai/reload —— 热重载(清缓存,无需重启)
func ReloadAIConfig(c *gin.Context) {
config.ResetCache()
if _, err := config.LoadAIConfig(true); err != nil {
web.Fail(c, web.NewLLMNotConfigured("重载后配置校验失败: "+err.Error()))
return
}
web.OK(c, gin.H{"reloaded": true})
}
// SecretsStatusHandler GET /api/ai/secrets-status —— 各 provider 密钥是否已配置(不回显明文)
func SecretsStatusHandler(c *gin.Context) {
web.OK(c, gin.H{"providers": config.SecretsStatus()})
}
@@ -0,0 +1,209 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/web"
)
// checkBalance 调用前校验用户算力点余额(不扣点的能力直接放行)
func checkBalance(c *gin.Context, user *model.User, capability string) bool {
if ai.ComputeCredits(capability, true) <= 0 {
return true
}
if user.AiPoints <= 0 {
web.Fail(c, web.NewPointsExhausted("AI 点数不足,请联系管理员充值"))
return false
}
return true
}
// ChatMessage POST /api/ai-chat/message —— SSE 流式对话(每轮扣点 + 审计)
func ChatMessage(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Message string `json:"message"`
Context map[string]any `json:"context"`
History []ai.Message `json:"history"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
web.Fail(c, web.NewBadRequest("message 必填"))
return
}
if !checkBalance(c, user, ai.CapabilityAIChat) {
return
}
route, err := config.GetRoute("path_coach")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error()))
return
}
knowledge := ai.Retrieve(Cfg, req.Message, 5)
messages := []ai.Message{{Role: "system", Content: buildSystemPrompt(req.Context, knowledge)}}
messages = append(messages, req.History...)
messages = append(messages, ai.Message{Role: "user", Content: req.Message})
client := ai.NewClient(route)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
web.Fail(c, web.NewLLMError("SSE flush 不支持"))
return
}
writeEvent := func(obj any) {
b, _ := json.Marshal(obj)
fmt.Fprintf(c.Writer, "data: %s\n\n", b)
flusher.Flush()
}
start := time.Now()
if err := client.GenerateStream(messages, func(chunk string) {
writeEvent(gin.H{"type": "text", "content": chunk})
}); err != nil {
writeEvent(gin.H{"type": "error", "message": err.Error()})
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
return
}
writeEvent(gin.H{"type": "done"})
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: true,
LatencyMs: int(time.Since(start).Milliseconds()),
})
}
func buildSystemPrompt(ctx map[string]any, knowledge []string) string {
ctxJSON, _ := json.Marshal(ctx)
kc := strings.Join(knowledge, "\n\n")
return fmt.Sprintf(`你是博昇内部培训平台的 AI 助教 PathCoach。
职责:
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关问题
2. 严格依赖已审批知识库内容回答
3. 知识库未找到相关资料时,明确回答「未找到相关资料」,不得臆测
禁止:
1. 禁止闲聊
2. 禁止编造数据
3. 禁止回答超出业务范围的问题
4. 禁止泄露敏感信息
当前页面上下文:
%s
知识库相关片段:
%s`, string(ctxJSON), kc)
}
// QuickActions GET /api/ai-chat/quick-actions —— 3 个快捷按钮
func QuickActions(c *gin.Context) {
web.OK(c, gin.H{"actions": []gin.H{
{"id": "scenario", "label": "客户情景演练"},
{"id": "commission", "label": "查询佣金/规则"},
{"id": "compare", "label": "产品对比"},
}})
}
// quickActionTask 每个快捷动作的检索词与任务说明
var quickActionTask = map[string]struct {
query string
task string
}{
"commission": {
query: "产品佣金 佣金规则 回款 结算 公开课奖励",
task: "请基于下方知识库,汇总相关产品的佣金比例、结算规则与奖励规则;知识库未覆盖的部分明确说明,不得臆测。",
},
"compare": {
query: "产品对比 定位 收费 适用场景",
task: "请基于下方知识库,对比相关产品的定位、收费与适用场景,突出差异。",
},
"scenario": {
query: "销售话术 销售流程 异议处理 情景演练",
task: "请基于下方知识库,扮演销售进行客户情景演练:推介相关产品或课程,并演示异议处理话术。",
},
}
// QuickAction POST /api/ai-chat/quick-action —— 非流式快捷动作(扣点 + 回退链 + 审计)
func QuickAction(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
ActionID string `json:"action_id"`
Params map[string]any `json:"params"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.ActionID == "" {
web.Fail(c, web.NewBadRequest("action_id 必填"))
return
}
if !checkBalance(c, user, ai.CapabilityTextGen) {
return
}
route, err := config.GetRoute("title_gen")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error()))
return
}
task, ok := quickActionTask[req.ActionID]
if !ok {
web.Fail(c, web.NewBadRequest("未知快捷动作"))
return
}
knowledge := ai.Retrieve(Cfg, task.query, 5)
systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task
messages := []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: task.query},
}
start := time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
web.Fail(c, web.NewLLMError("LLM 调用失败:"+err.Error()))
return
}
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: usedRoute.Provider,
RouteID: usedRoute.RouteID, Model: usedRoute.Model, Success: true,
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
LatencyMs: int(time.Since(start).Milliseconds()),
})
out := gin.H{"result": result.Content}
if req.ActionID == "scenario" {
out["mode"] = "scenario"
}
web.OK(c, out)
}
@@ -0,0 +1,166 @@
package api
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ──────────────────────────────────────────────
// 算力点用量查询(管理员 = 全量;员工 = 本人)
// ──────────────────────────────────────────────
// AIUsage GET /api/ai/usage?days=30&group_by=month|capability|provider
func AIUsage(c *gin.Context) {
user := middleware.CurrentUser(c)
days := parseIntDefault(c.Query("days"), 30)
if days <= 0 {
days = 30
}
groupBy := c.DefaultQuery("group_by", "month")
switch groupBy {
case "month", "day", "capability", "provider":
default:
groupBy = "month"
}
// 员工仅能看本人
userFilter := ""
args := []any{daysAgo(days)}
if user != nil && user.Role != "admin" {
userFilter = " AND user_id = ?"
args = append(args, user.ID)
}
var summary struct {
TotalCalls int64 `json:"total_calls"`
SuccessCalls int64 `json:"success_calls"`
FailedCalls int64 `json:"failed_calls"`
TotalCredits int64 `json:"total_credits"`
}
store.DB.Raw(`
SELECT COUNT(*) AS total_calls,
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END),0) AS success_calls,
COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END),0) AS failed_calls,
COALESCE(SUM(credits_charged),0) AS total_credits
FROM ai_call_log
WHERE created_at >= ?`+userFilter, args...).Scan(&summary)
// 分组字段(SQLite 下按月走 strftime,避免依赖 date_trunc)
var groupExpr, orderExpr string
switch groupBy {
case "capability":
groupExpr, orderExpr = "capability", "capability"
case "provider":
groupExpr, orderExpr = "COALESCE(provider,'')", "provider"
case "day":
groupExpr, orderExpr = "strftime('%Y-%m-%d', created_at)", "bucket"
default:
groupExpr, orderExpr = "strftime('%Y-%m', created_at)", "bucket"
}
type bucket struct {
Bucket string `json:"bucket"`
TotalCalls int64 `json:"total_calls"`
SuccessCalls int64 `json:"success_calls"`
FailedCalls int64 `json:"failed_calls"`
TotalCredits int64 `json:"total_credits"`
}
buckets := []bucket{}
store.DB.Raw(`
SELECT `+groupExpr+` AS bucket,
COUNT(*) AS total_calls,
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END),0) AS success_calls,
COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END),0) AS failed_calls,
COALESCE(SUM(credits_charged),0) AS total_credits
FROM ai_call_log
WHERE created_at >= ?`+userFilter+`
GROUP BY `+groupExpr+`
ORDER BY `+orderExpr, args...).Scan(&buckets)
web.OK(c, gin.H{"summary": summary, "buckets": buckets, "group_by": groupBy, "days": days})
}
// AIUsageUsers GET /api/ai/usage/users —— 管理员:按用户聚合用量 + 剩余点数
func AIUsageUsers(c *gin.Context) {
var users []model.User
store.DB.Order("id ASC").Find(&users)
type agg struct {
UserID uint `json:"user_id"`
TotalCalls int64 `json:"total_calls"`
TotalCredits int64 `json:"total_credits"`
}
var aggs []agg
store.DB.Raw(`SELECT user_id, COUNT(*) AS total_calls, COALESCE(SUM(credits_charged),0) AS total_credits FROM ai_call_log GROUP BY user_id`).Scan(&aggs)
aggMap := make(map[uint]agg, len(aggs))
for _, a := range aggs {
aggMap[a.UserID] = a
}
type userUsage struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
FullName string `json:"full_name"`
Role string `json:"role"`
AiPoints int `json:"ai_points"`
TotalCalls int64 `json:"total_calls"`
TotalCredits int64 `json:"total_credits"`
}
out := make([]userUsage, 0, len(users))
for _, u := range users {
a := aggMap[u.ID]
out = append(out, userUsage{
UserID: u.ID,
Username: u.Username,
FullName: u.FullName,
Role: u.Role,
AiPoints: u.AiPoints,
TotalCalls: a.TotalCalls,
TotalCredits: a.TotalCredits,
})
}
web.OK(c, gin.H{"users": out})
}
// AIUsageMe GET /api/ai/me —— 本人剩余点数 + 用量(PathCoach 面板数据源)
func AIUsageMe(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var totalUsed int64
store.DB.Raw(`SELECT COALESCE(SUM(credits_charged),0) FROM ai_call_log WHERE user_id = ?`, user.ID).Scan(&totalUsed)
var recent struct {
Used int64
Calls int64
}
store.DB.Raw(`SELECT COALESCE(SUM(credits_charged),0) AS used, COUNT(*) AS calls FROM ai_call_log WHERE user_id = ? AND created_at >= ?`, user.ID, daysAgo(30)).Scan(&recent)
web.OK(c, gin.H{
"ai_points": user.AiPoints,
"total_used": totalUsed,
"recent_used": recent.Used,
"recent_calls": recent.Calls,
})
}
// parseIntDefault 解析 int 查询参数,非法时返回默认值
func parseIntDefault(s string, def int) int {
if v, err := strconv.Atoi(s); err == nil {
return v
}
return def
}
// daysAgo 返回 n 天前的时间
func daysAgo(n int) time.Time {
return time.Now().AddDate(0, 0, -n)
}
@@ -0,0 +1,75 @@
package api
import (
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login 登录 → JWT
func Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
var user model.User
if err := store.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
web.Fail(c, web.NewAuthError("用户名或密码错误"))
return
}
if user.Status != "active" {
web.Fail(c, web.NewAuthError("账号已禁用"))
return
}
if !auth.VerifyPassword(req.Password, user.PasswordHash) {
web.Fail(c, web.NewAuthError("用户名或密码错误"))
return
}
token, err := auth.CreateToken(user.Username, user.Role, Cfg.JWTSecret, Cfg.JWTExpireMin)
if err != nil {
web.Fail(c, web.NewBadRequest("令牌签发失败"))
return
}
web.OK(c, gin.H{
"token": token,
"expires_in": Cfg.JWTExpireMin * 60,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"full_name": user.FullName,
"role": user.Role,
"ai_points": user.AiPoints,
},
})
}
// Me 当前用户信息
func Me(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
web.OK(c, gin.H{
"id": u.ID,
"username": u.Username,
"full_name": u.FullName,
"role": u.Role,
"status": u.Status,
"ai_points": u.AiPoints,
"learning_points": u.LearningPoints,
})
}
@@ -0,0 +1,69 @@
package api
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// issueCertificate 正式考试通过后颁发证书(幂等:同一 exam_record 只发一张)。
func issueCertificate(u *model.User, rec model.ExamRecord) {
if u == nil || !rec.Passed {
return
}
var n int64
store.DB.Model(&model.Certificate{}).Where("exam_record_id = ?", rec.ID).Count(&n)
if n > 0 {
return
}
certNo := fmt.Sprintf("BST-%06d", rec.ID)
store.DB.Create(&model.Certificate{
UserID: u.ID, ExamRecordID: rec.ID, UserName: u.FullName,
ExamName: rec.ExamName, Score: rec.Score, TotalScore: rec.TotalScore, PassScore: rec.PassScore,
CertNo: certNo, IssuedAt: time.Now(),
})
}
// MyCertificates GET /api/exam/certificates —— 我的证书列表
func MyCertificates(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var items []model.Certificate
store.DB.Where("user_id = ?", u.ID).Order("issued_at DESC").Find(&items)
web.OK(c, items)
}
// CertificateDetail GET /api/exam/certificates/{id} —— 证书详情(本人或管理员可看,用于打印留存)
func CertificateDetail(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var cert model.Certificate
if err := store.DB.First(&cert, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("证书不存在"))
return
}
if u == nil || (u.Role != "admin" && cert.UserID != u.ID) {
web.Fail(c, web.NewForbiddenError("无权查看他人证书"))
return
}
web.OK(c, cert)
}
// AdminCertificates GET /api/system/certificates —— 全员证书(管理员)
func AdminCertificates(c *gin.Context) {
var items []model.Certificate
store.DB.Order("issued_at DESC").Find(&items)
web.OK(c, items)
}
@@ -0,0 +1,115 @@
package api
import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// GetCompanyTrain GET /api/company-train
// content 取 system_config(company_intro),medias 取已审批的公司绑定素材
func GetCompanyTrain(c *gin.Context) {
content := ""
var cfg model.SystemConfig
if err := store.DB.Where("config_key = ?", "company_intro").First(&cfg).Error; err == nil {
content = cfg.ConfigValue
}
var files []model.MediaFile
store.DB.Where("bind_type = ? AND status = ?", "company", "approved").
Order("id ASC").Find(&files)
type mediaItem struct {
ID uint `json:"id"`
Filename string `json:"filename"`
FileExt string `json:"file_ext"`
PreviewURL string `json:"preview_url"`
}
medias := make([]mediaItem, 0, len(files))
for _, f := range files {
medias = append(medias, mediaItem{
ID: f.ID,
Filename: f.Filename,
FileExt: f.FileExt,
PreviewURL: "/api/media/preview/" + strconv.FormatUint(uint64(f.ID), 10),
})
}
web.OK(c, gin.H{"content": content, "medias": medias})
}
// SuggestMaterial POST /api/company-train/suggest-material —— 员工提交素材建议(弹窗)
// 员工提交 → pending,待管理员审批;与管理员直传自动通过区分(审批前置)。
func SuggestMaterial(c *gin.Context) {
u := middleware.CurrentUser(c)
if u.Role != "employee" {
web.Fail(c, web.NewForbiddenError("仅普通员工可提交素材建议,请使用资料管理中的管理员上传入口"))
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
web.Fail(c, web.NewBadRequest("缺少文件字段 file"))
return
}
defer file.Close()
remark := strings.TrimSpace(c.PostForm("remark"))
ext := extOf(header.Filename)
if !allowedExt[ext] {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
return
}
if header.Size > sizeLimitFor(ext) {
web.Fail(c, web.NewBadRequest("文件超过大小限制"))
return
}
storedName := randomID() + "." + ext
dst := filepath.Join(pendingDir(), storedName)
if err := os.MkdirAll(pendingDir(), 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建存储目录失败"))
return
}
out, err := os.Create(dst)
if err != nil {
web.Fail(c, web.NewBadRequest("保存文件失败"))
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
web.Fail(c, web.NewBadRequest("写入文件失败"))
return
}
out.Close()
m := model.MediaFile{
Filename: header.Filename,
StoredName: storedName,
StoredPath: storedName,
FileExt: ext,
FileSize: header.Size,
Status: "pending",
Source: "employee",
SubmitterID: u.ID,
BindType: "none",
Remark: remark,
}
if err := store.DB.Create(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
return
}
web.OK(c, gin.H{"media_id": m.ID, "status": m.Status, "remark": remark})
}
@@ -0,0 +1,59 @@
package api
import (
"errors"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/connector"
"eaisalestrain/backend/internal/web"
)
// ListConnectors GET /api/connectors
func ListConnectors(c *gin.Context) {
web.OK(c, connector.ListDefinitions(Cfg))
}
// GetConnector GET /api/connectors/:key
func GetConnector(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("连接器 key 不能为空"))
return
}
definition, ok := connector.GetDefinition(Cfg, key)
if !ok {
web.Fail(c, web.NewNotFoundError("连接器不存在"))
return
}
web.OK(c, definition)
}
// QueryConnector POST /api/connectors/:key/query
func QueryConnector(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("连接器 key 不能为空"))
return
}
var req connector.QueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
result, err := connector.Query(c.Request.Context(), Cfg, key, req)
if err != nil {
switch {
case errors.Is(err, connector.ErrConnectorNotFound):
web.Fail(c, web.NewNotFoundError("连接器不存在"))
case errors.Is(err, connector.ErrConnectorQueryUnsupported):
web.Fail(c, web.NewBadRequest("该连接器当前不支持 query,请在动作面板中作为输出连接器使用"))
default:
web.Fail(c, web.NewBadRequest(err.Error()))
}
return
}
web.OK(c, result)
}
@@ -0,0 +1,169 @@
package api
import (
"strconv"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// courseView 课程详情视图(含绑定产品)
func courseView(c *gin.Context, co model.Course) {
out := gin.H{
"id": co.ID,
"code": co.Code,
"name": co.Name,
"category": co.Category,
"target_customers": co.TargetCustomers,
"forbidden_customers": co.ForbiddenCustomers,
"scripts": co.Scripts,
"sales_process": co.SalesProcess,
"objection_handling": co.ObjectionHandling,
"delivery_pitfalls": co.DeliveryPitfalls,
"report_rules": co.ReportRules,
"related_product_id": co.RelatedProductID,
"status": co.Status,
"created_at": co.CreatedAt,
"updated_at": co.UpdatedAt,
}
if co.RelatedProductID != nil {
var p model.Product
if store.DB.Where("id = ? AND status != ?", *co.RelatedProductID, "inactive").First(&p).Error == nil {
out["product"] = gin.H{"id": p.ID, "code": p.Code, "name": p.Name, "category": p.Category}
}
}
var medias []model.MediaFile
store.DB.Where("bind_type = ? AND bind_id = ? AND status = ?", "course", co.ID, "approved").
Order("id ASC").Find(&medias)
if len(medias) > 0 {
items := make([]gin.H, 0, len(medias))
for _, m := range medias {
items = append(items, gin.H{
"id": m.ID,
"filename": m.Filename,
"file_ext": m.FileExt,
"preview_url": "/api/media/preview/" + strconv.FormatUint(uint64(m.ID), 10),
})
}
out["medias"] = items
}
web.OK(c, out)
}
// ListCourses GET /api/courses?category=&status=
func ListCourses(c *gin.Context) {
q := store.DB.Model(&model.Course{})
if cat := c.Query("category"); cat != "" {
q = q.Where("category = ?", cat)
}
switch st := c.Query("status"); st {
case "": // 默认仅 active(员工浏览)
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Course
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询课程失败"))
return
}
web.OK(c, items)
}
// GetCourse GET /api/courses/{id}
func GetCourse(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var co model.Course
if err := store.DB.First(&co, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("课程不存在"))
return
}
courseView(c, co)
}
// CreateCourse POST /api/courses (admin)
func CreateCourse(c *gin.Context) {
var co model.Course
if err := c.ShouldBindJSON(&co); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if co.Code == "" || co.Name == "" || co.Category == "" {
web.Fail(c, web.NewBadRequest("编号、名称、分类为必填"))
return
}
var count int64
store.DB.Model(&model.Course{}).Where("code = ?", co.Code).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("课程编号已存在"))
return
}
if co.Status == "" {
co.Status = "active"
}
if err := store.DB.Create(&co).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建课程失败"))
return
}
web.OK(c, co)
}
// UpdateCourse PUT /api/courses/{id} (admin) —— 支持绑定/解绑产品
func UpdateCourse(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var co model.Course
if err := store.DB.First(&co, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("课程不存在"))
return
}
var req model.Course
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.Name == "" || req.Category == "" {
web.Fail(c, web.NewBadRequest("名称、分类为必填"))
return
}
if req.Status == "" {
req.Status = "active"
}
if req.Code != "" && req.Code != co.Code {
var count int64
store.DB.Model(&model.Course{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("课程编号已存在"))
return
}
co.Code = req.Code
}
co.Name = req.Name
co.Category = req.Category
co.TargetCustomers = req.TargetCustomers
co.ForbiddenCustomers = req.ForbiddenCustomers
co.Scripts = req.Scripts
co.SalesProcess = req.SalesProcess
co.ObjectionHandling = req.ObjectionHandling
co.DeliveryPitfalls = req.DeliveryPitfalls
co.ReportRules = req.ReportRules
co.RelatedProductID = req.RelatedProductID
co.Status = req.Status
if err := store.DB.Save(&co).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新课程失败"))
return
}
courseView(c, co)
}
@@ -0,0 +1,275 @@
package api
import (
"fmt"
"sort"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ListDepartments GET /api/departments?status= —— 部门字典列表(含成员数)
func ListDepartments(c *gin.Context) {
q := store.DB.Model(&model.Department{})
switch st := c.Query("status"); st {
case "": // 默认仅 active
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Department
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询部门失败"))
return
}
// 成员数按 user.department 字符串匹配(部门为字典、用户以字符串归属)
var users []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Find(&users)
nameCount := map[string]int{}
for _, u := range users {
if strings.TrimSpace(u.Department) != "" {
nameCount[u.Department]++
}
}
out := make([]gin.H, 0, len(items))
for _, d := range items {
out = append(out, gin.H{
"id": d.ID,
"name": d.Name,
"description": d.Description,
"status": d.Status,
"member_count": nameCount[d.Name],
"created_at": d.CreatedAt,
})
}
web.OK(c, out)
}
// CreateDepartment POST /api/departments (admin)
func CreateDepartment(c *gin.Context) {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
web.Fail(c, web.NewBadRequest("部门名称为必填"))
return
}
req.Name = strings.TrimSpace(req.Name)
var n int64
store.DB.Model(&model.Department{}).Where("name = ?", req.Name).Count(&n)
if n > 0 {
web.Fail(c, web.NewConflictError("部门名称已存在"))
return
}
d := model.Department{Name: req.Name, Description: req.Description, Status: "active"}
if err := store.DB.Create(&d).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建部门失败"))
return
}
web.OK(c, d)
}
// UpdateDepartment PUT /api/departments/{id} (admin) —— 改名时同步 user.department 字符串
func UpdateDepartment(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var d model.Department
if err := store.DB.First(&d, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("部门不存在"))
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
web.Fail(c, web.NewBadRequest("部门名称为必填"))
return
}
req.Name = strings.TrimSpace(req.Name)
oldName := d.Name
if req.Name != oldName {
var n int64
store.DB.Model(&model.Department{}).Where("name = ? AND id <> ?", req.Name, id).Count(&n)
if n > 0 {
web.Fail(c, web.NewConflictError("部门名称已存在"))
return
}
}
d.Name = req.Name
d.Description = req.Description
if req.Status == "active" || req.Status == "inactive" {
d.Status = req.Status
}
if err := store.DB.Save(&d).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新部门失败"))
return
}
// 改名后同步员工归属,保证按部门统计与展示一致
if req.Name != oldName {
store.DB.Model(&model.User{}).Where("department = ?", oldName).Update("department", req.Name)
}
web.OK(c, d)
}
// DeleteDepartment DELETE /api/departments/{id} (admin) —— 有员工归属时拒绝删除
func DeleteDepartment(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var d model.Department
if err := store.DB.First(&d, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("部门不存在"))
return
}
var n int64
store.DB.Model(&model.User{}).Where("department = ? AND status = ?", d.Name, "active").Count(&n)
if n > 0 {
web.Fail(c, web.NewConflictError(fmt.Sprintf("该部门下仍有 %d 名员工,请先调整其部门", n)))
return
}
if err := store.DB.Delete(&d).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除部门失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
// DepartmentStats GET /api/system/department-stats —— 按部门学情聚合
func DepartmentStats(c *gin.Context) {
var employees []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Find(&employees)
// 每用户聚合:积分 / 考试 / 学习进度
type userAgg struct {
Points int
FormalCount int
FormalPassed int
ScoreSum int
CompanyViewed bool
ProductCount int
CourseCount int
}
perUser := map[uint]*userAgg{}
get := func(id uint) *userAgg {
a := perUser[id]
if a == nil {
a = &userAgg{}
perUser[id] = a
}
return a
}
for _, e := range employees {
get(e.ID).Points = e.LearningPoints
}
var recs []model.ExamRecord
store.DB.Find(&recs)
for _, r := range recs {
a := get(r.UserID)
a.FormalCount++
if r.Passed {
a.FormalPassed++
}
a.ScoreSum += r.Score
}
var lps []model.LearningProgress
store.DB.Find(&lps)
for _, lp := range lps {
a := get(lp.UserID)
switch lp.ItemType {
case "company":
a.CompanyViewed = true
case "product":
a.ProductCount++
case "course":
a.CourseCount++
}
}
// 按部门字符串聚合
type deptAgg struct {
EmployeeCount int
PointsTotal int
FormalCount int
FormalPassed int
ScoreSum int
CompanyViewed int
ProductSum int
CourseSum int
}
perDept := map[string]*deptAgg{}
order := []string{}
for _, e := range employees {
name := strings.TrimSpace(e.Department)
if name == "" {
name = "未分组"
}
a := perDept[name]
if a == nil {
a = &deptAgg{}
perDept[name] = a
order = append(order, name)
}
a.EmployeeCount++
if u := perUser[e.ID]; u != nil {
a.PointsTotal += u.Points
a.FormalCount += u.FormalCount
a.FormalPassed += u.FormalPassed
a.ScoreSum += u.ScoreSum
if u.CompanyViewed {
a.CompanyViewed++
}
a.ProductSum += u.ProductCount
a.CourseSum += u.CourseCount
}
}
// 排序:未分组沉底,其余按名称
sort.Slice(order, func(i, j int) bool {
if order[i] == "未分组" {
return false
}
if order[j] == "未分组" {
return true
}
return order[i] < order[j]
})
out := make([]gin.H, 0, len(order))
for _, name := range order {
a := perDept[name]
passRate, avgScore, companyRate, productAvg, courseAvg := 0.0, 0.0, 0.0, 0.0, 0.0
if a.FormalCount > 0 {
passRate = round1(float64(a.FormalPassed) * 100 / float64(a.FormalCount))
avgScore = round1(float64(a.ScoreSum) / float64(a.FormalCount))
}
if a.EmployeeCount > 0 {
companyRate = round1(float64(a.CompanyViewed) * 100 / float64(a.EmployeeCount))
productAvg = round1(float64(a.ProductSum) / float64(a.EmployeeCount))
courseAvg = round1(float64(a.CourseSum) / float64(a.EmployeeCount))
}
out = append(out, gin.H{
"department": name,
"employee_count": a.EmployeeCount,
"points_total": a.PointsTotal,
"formal_count": a.FormalCount,
"formal_passed": a.FormalPassed,
"pass_rate": passRate,
"avg_score": avgScore,
"company_rate": companyRate,
"product_avg": productAvg,
"course_avg": courseAvg,
})
}
web.OK(c, gin.H{"items": out})
}
@@ -0,0 +1,88 @@
package api
import (
"encoding/json"
"fmt"
"strings"
"time"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/config"
)
// gradeEssay 调 LLM 按评分标准对简答题作答打分,返回 0~1 得分率与一句话评语。
// 复用 text_gen 路由(title_gen),走回退链,全程审计。失败时返回 error 由调用方决定降级。
func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string, error) {
route, err := config.GetRoute("title_gen")
if err != nil {
return 0, "", err
}
systemPrompt := "你是培训平台阅卷老师。请严格依据「评分标准」对学员的简答题答案打分。" +
"只输出一个 JSON 对象,格式:{\"score\": <0到1之间的得分率数字>, \"comment\": \"<一句中文评语>\"}。" +
"不要输出任何其他内容。"
userPrompt := "题目:" + stem + "\n评分标准:" + rubric + "\n学员答案:" + userAnswer
messages := []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
}
start := time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
return 0, "", err
}
ai.LogCall(ai.LogEntry{
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: usedRoute.Provider,
RouteID: usedRoute.RouteID, Model: usedRoute.Model, Success: true,
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
LatencyMs: int(time.Since(start).Milliseconds()),
})
score, comment, err := parseEssayScore(result.Content)
if err != nil {
return 0, "", err
}
return score, comment, nil
}
// parseEssayScore 从 LLM 返回文本中提取 JSON 的 score / comment,score 钳制到 [0,1]。
func parseEssayScore(content string) (float64, string, error) {
s := strings.TrimSpace(content)
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
i := strings.Index(s, "{")
j := strings.LastIndex(s, "}")
if i < 0 || j <= i {
return 0, "", fmt.Errorf("LLM 评分输出非 JSON:%s", truncateScore(content))
}
var out struct {
Score float64 `json:"score"`
Comment string `json:"comment"`
}
if err := json.Unmarshal([]byte(s[i:j+1]), &out); err != nil {
return 0, "", fmt.Errorf("LLM 评分 JSON 解析失败: %w", err)
}
if out.Score < 0 {
out.Score = 0
}
if out.Score > 1 {
out.Score = 1
}
return out.Score, out.Comment, nil
}
func truncateScore(s string) string {
r := []rune(s)
if len(r) <= 120 {
return s
}
return string(r[:120]) + "..."
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
package api
import (
"reflect"
"testing"
)
func TestIsCorrect(t *testing.T) {
cases := []struct {
name string
qtype string
correct []string
user any
want bool
}{
{"single-correct", "single", []string{"A"}, "A", true},
{"single-wrong", "single", []string{"A"}, "B", false},
{"single-empty-correct", "single", []string{}, "A", false},
{"multiple-order-insensitive", "multiple", []string{"A", "B"}, []string{"B", "A"}, true},
{"multiple-missing", "multiple", []string{"A", "B"}, []string{"A"}, false},
{"multiple-extra", "multiple", []string{"A", "B"}, []string{"A", "B", "C"}, false},
{"multiple-wrong-type", "multiple", []string{"A"}, "A", false},
{"judge-true", "judge", []string{"true"}, "true", true},
{"judge-case-insensitive", "judge", []string{"true"}, "True", true},
{"judge-false", "judge", []string{"true"}, "false", false},
{"judge-empty-correct", "judge", []string{}, "true", false},
}
for _, c := range cases {
if got := isCorrect(c.qtype, c.correct, c.user); got != c.want {
t.Errorf("%s: isCorrect(%q,%v,%v)=%v want %v", c.name, c.qtype, c.correct, c.user, got, c.want)
}
}
}
func TestSortedEqual(t *testing.T) {
if !sortedEqual([]string{"A", "B"}, []string{"B", "A"}) {
t.Error("sortedEqual should be order-insensitive")
}
if sortedEqual([]string{"A"}, []string{"A", "B"}) {
t.Error("sortedEqual should reject different lengths")
}
if sortedEqual([]string{"A"}, []string{"B"}) {
t.Error("sortedEqual should reject different contents")
}
}
func TestSplitDomains(t *testing.T) {
if got := splitDomains("company, product"); !reflect.DeepEqual(got, []string{"company", "product"}) {
t.Errorf("splitDomains = %v", got)
}
if got := splitDomains(""); len(got) != 0 {
t.Errorf("splitDomains empty = %v", got)
}
if got := splitDomains(" , "); len(got) != 0 {
t.Errorf("splitDomains blanks = %v", got)
}
}
func TestSplitIDs(t *testing.T) {
if got := splitIDs("1,2,3"); !reflect.DeepEqual(got, []uint{1, 2, 3}) {
t.Errorf("splitIDs = %v", got)
}
if got := splitIDs("1, ,2"); !reflect.DeepEqual(got, []uint{1, 2}) {
t.Errorf("splitIDs with blank = %v", got)
}
if got := splitIDs("0,abc"); len(got) != 0 {
t.Errorf("splitIDs invalid = %v", got)
}
}
func TestDedupe(t *testing.T) {
if got := dedupeStrings([]string{"a", "b", "a", "", "c"}); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
t.Errorf("dedupeStrings = %v", got)
}
if got := dedupeUints([]uint{1, 2, 1, 3}); !reflect.DeepEqual(got, []uint{1, 2, 3}) {
t.Errorf("dedupeUints = %v", got)
}
}
func TestNormalizeUserAnswer(t *testing.T) {
if got := normalizeUserAnswer("text"); got != "text" {
t.Errorf("string = %v", got)
}
if got := normalizeUserAnswer(true); got != "true" {
t.Errorf("bool = %v", got)
}
if got := normalizeUserAnswer([]string{"A", "B"}); !reflect.DeepEqual(got, []string{"A", "B"}) {
t.Errorf("[]string = %v", got)
}
if got := normalizeUserAnswer([]any{"A", 1}); !reflect.DeepEqual(got, []string{"A", "1"}) {
t.Errorf("[]any = %v", got)
}
if got := normalizeUserAnswer(nil); got != nil {
t.Errorf("nil = %v", got)
}
if got := normalizeUserAnswer(5.0); got != "5" {
t.Errorf("number = %v", got)
}
}
func TestParseEssayScore(t *testing.T) {
s, c, err := parseEssayScore(`{"score":0.8,"comment":"要点齐全"}`)
if err != nil || s != 0.8 || c != "要点齐全" {
t.Errorf("basic = %v,%v,%v", s, c, err)
}
s, _, err = parseEssayScore("```json\n{\"score\":0.5}\n```")
if err != nil || s != 0.5 {
t.Errorf("fenced = %v,%v", s, err)
}
s, _, err = parseEssayScore(`{"score":1.5}`)
if err != nil || s != 1 {
t.Errorf("clamp-high = %v,%v", s, err)
}
s, _, err = parseEssayScore(`{"score":-0.2}`)
if err != nil || s != 0 {
t.Errorf("clamp-low = %v,%v", s, err)
}
if _, _, err = parseEssayScore("评分 0.3 分"); err == nil {
t.Error("non-json should error")
}
}
func TestScoreBand(t *testing.T) {
cases := map[int]string{55: "0-59", 60: "60-69", 69: "60-69", 70: "70-79", 89: "80-89", 90: "90-100", 100: "90-100"}
for in, want := range cases {
if got := scoreBand(in); got != want {
t.Errorf("scoreBand(%d)=%s want %s", in, got, want)
}
}
}
func TestRound1(t *testing.T) {
if round1(66.66) != 66.7 {
t.Errorf("round1(66.66)=%v", round1(66.66))
}
if round1(50.0) != 50.0 {
t.Errorf("round1(50.0)=%v", round1(50.0))
}
}
@@ -0,0 +1,19 @@
package api
import (
"strconv"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/web"
)
// parseID 解析路径中的 uint id,失败返回 false 并已写入错误响应
func parseID(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
web.Fail(c, web.NewBadRequest("无效的 "+name))
return 0, false
}
return uint(id), true
}
@@ -0,0 +1,391 @@
package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ============ 扫描 ============
// 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 // 非知识源文档,跳过
}
var existing model.KnowledgeSource
if err := store.DB.Where("file_path = ?", e.Name()).First(&existing).Error; err == nil {
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",
}
if err := store.DB.Create(&src).Error; err != nil {
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})
}
web.OK(c, gin.H{"results": results})
}
// KnowledgeAuditList GET /api/knowledge/audit-list?status=&page=&size=
func KnowledgeAuditList(c *gin.Context) {
q := store.DB.Model(&model.KnowledgeSource{})
if s := c.Query("status"); s != "" {
q = q.Where("audit_status = ?", s)
}
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
}
var total int64
q.Count(&total)
var items []model.KnowledgeSource
q.Order("id ASC").Offset((page - 1) * size).Limit(size).Find(&items)
web.OK(c, gin.H{"total": total, "items": items})
}
// KnowledgeAudit POST /api/knowledge/audit/{sourceId} —— 审批(approve→摄入 / reject→驳回)
func KnowledgeAudit(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "sourceId")
if !ok {
return
}
var src model.KnowledgeSource
if err := store.DB.First(&src, id).Error; err != nil {
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 err := store.DB.Save(&src).Error; err != nil {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
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 err := store.DB.Save(&src).Error; err != nil {
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/{sourceId}
func KnowledgeStatus(c *gin.Context) {
id, ok := parseID(c, "sourceId")
if !ok {
return
}
var src model.KnowledgeSource
if err := store.DB.First(&src, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("知识源不存在"))
return
}
web.OK(c, gin.H{
"audit_status": src.AuditStatus,
"ingested": src.Ingested,
"reject_reason": src.RejectReason,
})
}
// ============ 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,返回三表写入计数
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",
}
var existing model.Product
if err := store.DB.Where("code = ?", p.Code).First(&existing).Error; err == nil {
p.ID = existing.ID
store.DB.Save(&p)
} else {
store.DB.Create(&p)
}
counts[0]++
}
// 2. AI 检索知识 → knowledge_chunk
for i, b := range splitBlocks(sections["AI 检索知识"]) {
text := strings.TrimSpace(b.body)
if text == "" {
continue
}
store.DB.Create(&model.KnowledgeChunk{
KnowledgeSourceID: &src.ID,
SourceType: "md",
SourceID: strconv.FormatUint(uint64(src.ID), 10),
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",
}
store.DB.Create(&q)
counts[2]++
}
return counts, nil
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
@@ -0,0 +1,338 @@
package api
import (
"encoding/csv"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ============ 知识库导出 ============
//
// GET /api/knowledge/export?type=manual|questions|products&format=md|csv
// - manual:培训知识手册 md(产品 + 课程 + AI 检索知识 + 题库含答案)
// - questions:题库(md 或 csv)
// - products:产品清单(csv 或 md)
var productCatLabel = map[string]string{
"capital_consulting": "资本咨询类",
"qualification_counseling": "资质认定辅导类",
"ai_consulting": "AI 咨询类",
"ai_tools_platform": "AI 工具平台类",
}
var courseCatLabel = map[string]string{
"capital_script": "资本咨询话术",
"qualification_logic": "资质认定逻辑",
"ai_public_course": "AI 公开课",
"ai_platform_matching": "AI 平台匹配",
}
var domainLabel = map[string]string{
"company": "公司知识",
"product": "产品知识",
"sales": "销售知识",
}
var qtypeLabel = map[string]string{
"single": "单选题",
"multiple": "多选题",
"judge": "判断题",
"essay": "简答题",
}
func labelOf(m map[string]string, k string) string {
if v, ok := m[k]; ok {
return v
}
return k
}
// ExportKnowledge 知识库导出(管理员)
func ExportKnowledge(c *gin.Context) {
typ := c.DefaultQuery("type", "manual")
format := c.DefaultQuery("format", "md")
switch typ {
case "questions":
exportQuestions(c, format)
case "products":
exportProducts(c, format)
case "manual":
exportManual(c)
default:
web.Fail(c, web.NewBadRequest("type 必须为 manual / questions / products"))
}
}
// ---------- 数据读取 ----------
type qView struct {
Domain string
Type string
Stem string
Options []Option
Answer []string
Explanation string
}
func loadQuestions() []qView {
var items []model.Question
store.DB.Where("status = ?", "active").Order("id ASC").Find(&items)
out := make([]qView, 0, len(items))
for _, q := range items {
var opts []Option
_ = json.Unmarshal([]byte(q.Options), &opts)
var ans []string
_ = json.Unmarshal([]byte(q.Answer), &ans)
out = append(out, qView{
Domain: q.Domain, Type: q.Type, Stem: q.Stem,
Options: opts, Answer: ans, Explanation: q.Explanation,
})
}
return out
}
// answerText 答案 → 可读文本(判断题→正确/错误;选择→A、B)
func answerText(q qView) string {
if q.Type == "judge" {
if len(q.Answer) > 0 && (q.Answer[0] == "true" || q.Answer[0] == "正确") {
return "正确"
}
return "错误"
}
return strings.Join(q.Answer, "、")
}
// optionsText 选项 → "A. xxx;B. yyy"
func optionsText(q qView) string {
parts := make([]string, 0, len(q.Options))
for _, o := range q.Options {
parts = append(parts, o.Key+". "+o.Text)
}
return strings.Join(parts, ";")
}
// ---------- 导出:题库 ----------
func exportQuestions(c *gin.Context, format string) {
qs := loadQuestions()
if format == "csv" {
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="题库_`+time.Now().Format("20060102")+`.csv"`)
c.Writer.WriteString("\xEF\xBB\xBF")
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"编号", "题型", "知识域", "题干", "选项", "答案", "解析"})
for i, q := range qs {
_ = w.Write([]string{
strconv.Itoa(i + 1),
labelOf(qtypeLabel, q.Type),
labelOf(domainLabel, q.Domain),
q.Stem,
optionsText(q),
answerText(q),
q.Explanation,
})
}
w.Flush()
return
}
// md(默认)
var b strings.Builder
b.WriteString("# 题库(含答案)\n\n")
b.WriteString("> 导出时间:" + time.Now().Format("2006-01-02 15:04") + " · 共 " + strconv.Itoa(len(qs)) + " 题\n\n")
writeQuestionsMarkdown(&b, qs)
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="题库_`+time.Now().Format("20060102")+`.md"`)
c.String(200, b.String())
}
// ---------- 导出:产品清单 ----------
func exportProducts(c *gin.Context, format string) {
var items []model.Product
store.DB.Where("status = ?", "active").Order("id ASC").Find(&items)
if format == "md" {
var b strings.Builder
b.WriteString("# 产品清单\n\n")
b.WriteString("> 导出时间:" + time.Now().Format("2006-01-02 15:04") + " · 共 " + strconv.Itoa(len(items)) + " 个产品\n\n")
writeProductsMarkdown(&b, items)
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="产品清单_`+time.Now().Format("20060102")+`.md"`)
c.String(200, b.String())
return
}
// csv(默认)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="产品清单_`+time.Now().Format("20060102")+`.csv"`)
c.Writer.WriteString("\xEF\xBB\xBF")
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"编号", "产品名称", "分类", "标签", "说明", "收费", "佣金(推荐)", "佣金(谈单)", "公开课奖励", "版本风险", "报备规则"})
for i, p := range items {
_ = w.Write([]string{
strconv.Itoa(i + 1),
p.Name,
labelOf(productCatLabel, p.Category),
p.Tags,
p.Description,
p.Pricing,
p.CommissionRecommend,
p.CommissionNegotiate,
p.PublicCourseBonus,
p.VersionRisk,
p.ReportRules,
})
}
w.Flush()
}
// ---------- 导出:培训手册(md) ----------
func exportManual(c *gin.Context) {
var products []model.Product
store.DB.Where("status = ?", "active").Order("id ASC").Find(&products)
var courses []model.Course
store.DB.Where("status = ?", "active").Order("id ASC").Find(&courses)
var chunks []model.KnowledgeChunk
store.DB.Order("id ASC").Find(&chunks)
qs := loadQuestions()
var b strings.Builder
b.WriteString("# 博昇内部培训知识手册\n\n")
b.WriteString("> 导出时间:" + time.Now().Format("2006-01-02 15:04") + "\n")
b.WriteString("> 内容:产品知识 · 销售课程 · AI 检索知识 · 考试题库\n\n")
// 一、产品知识
b.WriteString("## 一、产品知识(" + strconv.Itoa(len(products)) + " 个)\n\n")
writeProductsMarkdown(&b, products)
// 二、销售课程
b.WriteString("## 二、销售课程(" + strconv.Itoa(len(courses)) + " 门)\n\n")
for i, co := range courses {
b.WriteString("### " + strconv.Itoa(i+1) + ". " + co.Name + "(" + labelOf(courseCatLabel, co.Category) + ")\n\n")
if co.TargetCustomers != "" {
b.WriteString("- 目标客户:" + strings.TrimSpace(co.TargetCustomers) + "\n")
}
if co.ForbiddenCustomers != "" {
b.WriteString("- 禁区客户:" + strings.TrimSpace(co.ForbiddenCustomers) + "\n")
}
if co.Scripts != "" {
b.WriteString("- 话术:" + strings.TrimSpace(co.Scripts) + "\n")
}
if co.SalesProcess != "" {
b.WriteString("- 销售流程:" + strings.TrimSpace(co.SalesProcess) + "\n")
}
if co.ObjectionHandling != "" {
b.WriteString("- 异议处理:" + strings.TrimSpace(co.ObjectionHandling) + "\n")
}
if co.DeliveryPitfalls != "" {
b.WriteString("- 避坑提醒:" + strings.TrimSpace(co.DeliveryPitfalls) + "\n")
}
if co.ReportRules != "" {
b.WriteString("- 报备规则:" + strings.TrimSpace(co.ReportRules) + "\n")
}
b.WriteString("\n")
}
// 三、AI 检索知识
b.WriteString("## 三、AI 检索知识(" + strconv.Itoa(len(chunks)) + " 个知识块)\n\n")
for i, ch := range chunks {
content := strings.TrimSpace(ch.Content)
if content == "" {
continue
}
b.WriteString("### 知识点 " + strconv.Itoa(i+1) + "\n\n")
b.WriteString(content + "\n\n")
}
// 四、考试题库
b.WriteString("## 四、考试题库(含答案,共 " + strconv.Itoa(len(qs)) + " 题)\n\n")
writeQuestionsMarkdown(&b, qs)
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="培训知识手册_`+time.Now().Format("20060102")+`.md"`)
c.String(200, b.String())
}
// writeProductsMarkdown 产品 → 分组 markdown
func writeProductsMarkdown(b *strings.Builder, items []model.Product) {
// 按分类分组,保持稳定顺序
groups := make(map[string][]model.Product)
var order []string
for _, p := range items {
if _, ok := groups[p.Category]; !ok {
order = append(order, p.Category)
}
groups[p.Category] = append(groups[p.Category], p)
}
idx := 0
for _, cat := range order {
ps := groups[cat]
for _, p := range ps {
idx++
b.WriteString("### " + strconv.Itoa(idx) + ". " + p.Name + "(" + labelOf(productCatLabel, p.Category) + ")\n\n")
if p.Tags != "" {
b.WriteString("- 标签:" + strings.TrimSpace(p.Tags) + "\n")
}
if p.Description != "" {
b.WriteString("- 说明:" + strings.TrimSpace(p.Description) + "\n")
}
if p.Pricing != "" {
b.WriteString("- 收费:" + strings.TrimSpace(p.Pricing) + "\n")
}
if p.CommissionRecommend != "" {
b.WriteString("- 佣金(仅推荐):" + strings.TrimSpace(p.CommissionRecommend) + "\n")
}
if p.CommissionNegotiate != "" {
b.WriteString("- 佣金(参与谈单):" + strings.TrimSpace(p.CommissionNegotiate) + "\n")
}
if p.PublicCourseBonus != "" {
b.WriteString("- 公开课奖励:" + strings.TrimSpace(p.PublicCourseBonus) + "\n")
}
if p.VersionRisk != "" {
b.WriteString("- 版本风险:" + strings.TrimSpace(p.VersionRisk) + "\n")
}
if p.ReportRules != "" {
b.WriteString("- 报备规则:" + strings.TrimSpace(p.ReportRules) + "\n")
}
b.WriteString("\n")
}
}
}
// writeQuestionsMarkdown 题目 → 按知识域分组 markdown
func writeQuestionsMarkdown(b *strings.Builder, qs []qView) {
groups := make(map[string][]qView)
var order []string
for _, q := range qs {
if _, ok := groups[q.Domain]; !ok {
order = append(order, q.Domain)
}
groups[q.Domain] = append(groups[q.Domain], q)
}
for _, d := range order {
qs := groups[d]
b.WriteString("### " + labelOf(domainLabel, d) + "(" + strconv.Itoa(len(qs)) + " 题)\n\n")
for i, q := range qs {
b.WriteString(strconv.Itoa(i+1) + ". [" + labelOf(qtypeLabel, q.Type) + "] " + q.Stem + "\n")
if len(q.Options) > 0 {
for _, o := range q.Options {
b.WriteString(" - " + o.Key + ". " + o.Text + "\n")
}
}
b.WriteString(" 答案:" + answerText(q) + "\n")
if q.Explanation != "" {
b.WriteString(" 解析:" + q.Explanation + "\n")
}
b.WriteString("\n")
}
}
}
@@ -0,0 +1,114 @@
package api
import (
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
var validItemTypes = map[string]bool{"company": true, "product": true, "course": true}
// RecordLearningProgress POST /api/learning/progress —— 员工浏览内容时上报进度
func RecordLearningProgress(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
ItemType string `json:"item_type"`
ItemID uint `json:"item_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || !validItemTypes[req.ItemType] {
web.Fail(c, web.NewBadRequest("item_type 必填且合法(company/product/course)"))
return
}
if req.ItemType == "company" {
req.ItemID = 0
}
lp := model.LearningProgress{UserID: u.ID, ItemType: req.ItemType, ItemID: req.ItemID}
// 幂等:已存在则仅刷新 updated_at;首次记录才加分(避免重复刷分)
var existing model.LearningProgress
if err := store.DB.Where("user_id = ? AND item_type = ? AND item_id = ?", u.ID, req.ItemType, req.ItemID).
First(&existing).Error; err != nil {
if createErr := store.DB.Create(&lp).Error; createErr != nil {
web.Fail(c, web.NewBadRequest("记录学习进度失败"))
return
}
awardFirstView(u.ID, req.ItemType, req.ItemID)
} else {
store.DB.Model(&existing).Update("updated_at", time.Now())
}
web.OK(c, gin.H{"recorded": true, "item_type": req.ItemType, "item_id": req.ItemID})
}
// MyLearningProgress GET /api/learning/me —— 我的学习进度
func MyLearningProgress(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var items []model.LearningProgress
store.DB.Where("user_id = ?", u.ID).Order("updated_at DESC").Find(&items)
web.OK(c, items)
}
// AdminLearningProgress GET /api/system/learning-progress —— 全员学习进度(管理员)
func AdminLearningProgress(c *gin.Context) {
var employees []model.User
store.DB.Where("role = ?", "employee").Order("id ASC").Find(&employees)
var items []model.LearningProgress
store.DB.Order("updated_at DESC").Find(&items)
type agg struct {
CompanyViewed bool `json:"company_viewed"`
ProductCount int `json:"product_count"`
CourseCount int `json:"course_count"`
TotalItems int `json:"total_items"`
LastViewedAt *time.Time `json:"last_viewed_at"`
}
perUser := map[uint]*agg{}
for _, lp := range items {
a := perUser[lp.UserID]
if a == nil {
a = &agg{}
perUser[lp.UserID] = a
}
switch lp.ItemType {
case "company":
a.CompanyViewed = true
case "product":
a.ProductCount++
case "course":
a.CourseCount++
}
a.TotalItems++
if a.LastViewedAt == nil || lp.UpdatedAt.After(*a.LastViewedAt) {
t := lp.UpdatedAt
a.LastViewedAt = &t
}
}
out := make([]gin.H, 0, len(employees))
for _, e := range employees {
a := perUser[e.ID]
if a == nil {
a = &agg{}
}
out = append(out, gin.H{
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
"department": e.Department, "position": e.Position,
"company_viewed": a.CompanyViewed, "product_count": a.ProductCount,
"course_count": a.CourseCount, "total_items": a.TotalItems,
"last_viewed_at": a.LastViewedAt,
})
}
web.OK(c, gin.H{"users": out})
}
+596
View File
@@ -0,0 +1,596 @@
package api
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
var allowedExt = map[string]bool{
"ppt": true, "pptx": true, "pdf": true, "doc": true, "docx": true,
"mp4": true, "png": true, "jpg": true, "jpeg": true,
}
var validBindType = map[string]bool{"company": true, "product": true, "course": true, "none": true}
const defaultChunkSize = 5 * 1024 * 1024 // 5MB
// randomID 生成随机十六进制标识(文件名/上传会话)
func randomID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b)
}
func extOf(filename string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
}
func sizeLimitFor(ext string) int64 {
if ext == "mp4" {
return Cfg.FileMaxVideo
}
return Cfg.FileMaxDoc
}
// 审批前置:未审批文件存 pending/、驳回文件存 rejected/ 子目录(均不对外公开),
// 审批通过后移动到 approved/ 子目录(静态公开,仅 approved 可访问)。
func approvedDir() string { return filepath.Join(Cfg.KBDataDir, "approved") }
func pendingDir() string { return filepath.Join(Cfg.KBDataDir, "pending") }
func rejectedDir() string { return filepath.Join(Cfg.KBDataDir, "rejected") }
// mediaPathFor 按状态返回素材物理路径
func mediaPathFor(m model.MediaFile) string {
switch m.Status {
case "approved":
return filepath.Join(approvedDir(), m.StoredName)
case "rejected":
return filepath.Join(rejectedDir(), m.StoredName)
default:
return filepath.Join(pendingDir(), m.StoredName)
}
}
func parseBind(c *gin.Context) (string, *uint) {
bt := c.PostForm("bind_type")
if bt == "" {
bt = "none"
}
if !validBindType[bt] {
bt = "none"
}
var bid *uint
if s := c.PostForm("bind_id"); s != "" {
if n, err := strconv.ParseUint(s, 10, 64); err == nil && n > 0 {
v := uint(n)
bid = &v
}
}
return bt, bid
}
func sourceOf(c *gin.Context) string {
u := middleware.CurrentUser(c)
if u != nil && u.Role == "admin" {
return "admin"
}
return "employee"
}
// ============ 直传 ============
// Upload POST /api/media/upload —— 文档/小视频直传
func Upload(c *gin.Context) {
u := middleware.CurrentUser(c)
file, header, err := c.Request.FormFile("file")
if err != nil {
web.Fail(c, web.NewBadRequest("缺少文件字段 file"))
return
}
defer file.Close()
ext := extOf(header.Filename)
if !allowedExt[ext] {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
return
}
if header.Size > sizeLimitFor(ext) {
web.Fail(c, web.NewBadRequest("文件超过大小限制"))
return
}
bindType, bindID := parseBind(c)
source := sourceOf(c)
status := "pending"
targetDir := pendingDir()
if source == "admin" {
status = "approved" // 管理员上传自动通过
targetDir = approvedDir()
}
storedName := randomID() + "." + ext
dst := filepath.Join(targetDir, storedName)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建存储目录失败"))
return
}
out, err := os.Create(dst)
if err != nil {
web.Fail(c, web.NewBadRequest("保存文件失败"))
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
web.Fail(c, web.NewBadRequest("写入文件失败"))
return
}
out.Close()
m := model.MediaFile{
Filename: header.Filename,
StoredName: storedName,
StoredPath: storedName,
FileExt: ext,
FileSize: header.Size,
Status: status,
Source: source,
SubmitterID: u.ID,
BindType: bindType,
BindID: bindID,
}
if err := store.DB.Create(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
return
}
if status == "approved" {
go runExtractPipeline(m.ID)
}
web.OK(c, gin.H{"media_id": m.ID, "status": m.Status, "filename": m.Filename})
}
// ============ 分片上传 ============
type uploadSession struct {
Filename string
FileSize int64
Ext string
BindType string
BindID *uint
ChunkSize int64
ChunkCount int
Chunks map[int]bool
}
var uploadSessions = struct {
sync.RWMutex
m map[string]*uploadSession
}{m: map[string]*uploadSession{}}
// UploadInit POST /api/media/upload-init —— 初始化分片上传
func UploadInit(c *gin.Context) {
var req struct {
Filename string `json:"filename"`
FileSize int64 `json:"file_size"`
BindType string `json:"bind_type"`
BindID *uint `json:"bind_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Filename == "" || req.FileSize <= 0 {
web.Fail(c, web.NewBadRequest("filename/file_size 必填"))
return
}
ext := extOf(req.Filename)
if !allowedExt[ext] {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
if req.FileSize > sizeLimitFor(ext) {
web.Fail(c, web.NewBadRequest("文件超过大小限制"))
return
}
if req.BindType == "" || !validBindType[req.BindType] {
req.BindType = "none"
}
id := randomID()
chunkCount := int((req.FileSize + defaultChunkSize - 1) / defaultChunkSize)
sess := &uploadSession{
Filename: req.Filename, FileSize: req.FileSize, Ext: ext,
BindType: req.BindType, BindID: req.BindID,
ChunkSize: defaultChunkSize, ChunkCount: chunkCount, Chunks: map[int]bool{},
}
uploadSessions.Lock()
uploadSessions.m[id] = sess
uploadSessions.Unlock()
web.OK(c, gin.H{"upload_id": id, "chunk_size": defaultChunkSize, "chunk_count": chunkCount})
}
// UploadChunk POST /api/media/upload-chunk —— 上传分片
func UploadChunk(c *gin.Context) {
id := c.PostForm("upload_id")
idx, err := strconv.Atoi(c.PostForm("chunk_index"))
if err != nil || id == "" {
web.Fail(c, web.NewBadRequest("upload_id/chunk_index 必填"))
return
}
uploadSessions.RLock()
sess := uploadSessions.m[id]
uploadSessions.RUnlock()
if sess == nil {
web.Fail(c, web.NewNotFoundError("上传会话不存在"))
return
}
if idx < 0 || idx >= sess.ChunkCount {
web.Fail(c, web.NewBadRequest("chunk_index 越界"))
return
}
file, _, err := c.Request.FormFile("file")
if err != nil {
web.Fail(c, web.NewBadRequest("缺少分片文件"))
return
}
defer file.Close()
tmpDir := filepath.Join(pendingDir(), "tmp", id)
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建临时目录失败"))
return
}
dst := filepath.Join(tmpDir, fmt.Sprintf("%06d", idx))
out, err := os.Create(dst)
if err != nil {
web.Fail(c, web.NewBadRequest("保存分片失败"))
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
web.Fail(c, web.NewBadRequest("写入分片失败"))
return
}
out.Close()
uploadSessions.Lock()
sess.Chunks[idx] = true
uploadSessions.Unlock()
web.OK(c, gin.H{"upload_id": id, "chunk_index": idx, "received": len(sess.Chunks)})
}
// UploadComplete POST /api/media/upload-complete —— 合并分片
func UploadComplete(c *gin.Context) {
u := middleware.CurrentUser(c)
var req struct {
UploadID string `json:"upload_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.UploadID == "" {
web.Fail(c, web.NewBadRequest("upload_id 必填"))
return
}
uploadSessions.RLock()
sess := uploadSessions.m[req.UploadID]
uploadSessions.RUnlock()
if sess == nil {
web.Fail(c, web.NewNotFoundError("上传会话不存在"))
return
}
if len(sess.Chunks) != sess.ChunkCount {
web.Fail(c, web.NewBadRequest(fmt.Sprintf("分片不完整:%d/%d", len(sess.Chunks), sess.ChunkCount)))
return
}
source := sourceOf(c)
status := "pending"
targetDir := pendingDir()
if source == "admin" {
status = "approved" // 管理员上传自动通过
targetDir = approvedDir()
}
storedName := randomID() + "." + sess.Ext
if err := os.MkdirAll(targetDir, 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建存储目录失败"))
return
}
final := filepath.Join(targetDir, storedName)
out, err := os.Create(final)
if err != nil {
web.Fail(c, web.NewBadRequest("创建最终文件失败"))
return
}
tmpDir := filepath.Join(pendingDir(), "tmp", req.UploadID)
for i := 0; i < sess.ChunkCount; i++ {
part := filepath.Join(tmpDir, fmt.Sprintf("%06d", i))
f, err := os.Open(part)
if err != nil {
out.Close()
web.Fail(c, web.NewBadRequest("读取分片失败"))
return
}
if _, err := io.Copy(out, f); err != nil {
f.Close()
out.Close()
web.Fail(c, web.NewBadRequest("合并分片失败"))
return
}
f.Close()
}
out.Close()
os.RemoveAll(tmpDir)
uploadSessions.Lock()
delete(uploadSessions.m, req.UploadID)
uploadSessions.Unlock()
m := model.MediaFile{
Filename: sess.Filename, StoredName: storedName, StoredPath: storedName,
FileExt: sess.Ext, FileSize: sess.FileSize, Status: status,
Source: source, SubmitterID: u.ID, BindType: sess.BindType, BindID: sess.BindID,
}
if err := store.DB.Create(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
return
}
if status == "approved" {
go runExtractPipeline(m.ID)
}
web.OK(c, gin.H{"media_id": m.ID, "status": m.Status, "filename": m.Filename})
}
// ============ 预览 / 状态 ============
// Preview GET /api/media/preview/{mediaId} —— 仅 approved 可预览
func Preview(c *gin.Context) {
id, ok := parseID(c, "mediaId")
if !ok {
return
}
var m model.MediaFile
if err := store.DB.First(&m, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("素材不存在"))
return
}
if m.Status != "approved" {
web.Fail(c, web.NewForbiddenError("素材未通过审批,不可预览"))
return
}
web.OK(c, gin.H{
"preview_url": "/media/" + m.StoredName,
"file_ext": m.FileExt,
"can_preview": true,
})
}
// MediaStatus GET /api/media/status/{mediaId}
func MediaStatus(c *gin.Context) {
id, ok := parseID(c, "mediaId")
if !ok {
return
}
var m model.MediaFile
if err := store.DB.First(&m, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("素材不存在"))
return
}
var chunkCount int64
store.DB.Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", m.ID).Count(&chunkCount)
web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount})
}
// ============ 审批 ============
// AuditList GET /api/media/audit-list?status=&page=&size=
func AuditList(c *gin.Context) {
q := store.DB.Model(&model.MediaFile{})
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
}
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
}
var total int64
q.Count(&total)
var items []model.MediaFile
q.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items)
web.OK(c, gin.H{"total": total, "items": items})
}
// AuditMedia POST /api/media/audit/{mediaId} —— 审批(通过→触发异步提取)
func AuditMedia(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "mediaId")
if !ok {
return
}
var m model.MediaFile
if err := store.DB.First(&m, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("素材不存在"))
return
}
if m.Status != "pending" {
web.Fail(c, web.NewConflictError("该素材已审批,不可重复操作"))
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":
// 审批前置:通过后才把文件从 pending/ 移动到 approved/(公开)
src := filepath.Join(pendingDir(), m.StoredName)
dst := filepath.Join(approvedDir(), m.StoredName)
if err := os.MkdirAll(approvedDir(), 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建公开目录失败"))
return
}
if err := os.Rename(src, dst); err != nil {
web.Fail(c, web.NewBadRequest("移动文件失败:"+err.Error()))
return
}
m.Status = "approved"
m.AuditBy = &auditBy
m.AuditAt = &now
m.RejectReason = ""
case "reject":
if strings.TrimSpace(req.RejectReason) == "" {
web.Fail(c, web.NewBadRequest("驳回理由必填"))
return
}
// 驳回:把文件从 pending/ 移到 rejected/,落实「审批前置」物理隔离
src := filepath.Join(pendingDir(), m.StoredName)
dst := filepath.Join(rejectedDir(), m.StoredName)
if err := os.MkdirAll(rejectedDir(), 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建驳回目录失败"))
return
}
if err := os.Rename(src, dst); err != nil {
web.Fail(c, web.NewBadRequest("移动文件失败:"+err.Error()))
return
}
m.Status = "rejected"
m.RejectReason = req.RejectReason
m.AuditBy = &auditBy
m.AuditAt = &now
default:
web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject"))
return
}
if err := store.DB.Save(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
if m.Status == "approved" {
go runExtractPipeline(m.ID)
}
web.OK(c, gin.H{"id": m.ID, "status": m.Status})
}
// ============ 异步文档提取管线 ============
// runExtractPipeline 审批通过后:文档转 PDF → pdftotext → 切片入库
func runExtractPipeline(mediaID uint) {
var m model.MediaFile
if err := store.DB.First(&m, mediaID).Error; err != nil {
return
}
docExts := map[string]bool{"ppt": true, "pptx": true, "doc": true, "docx": true}
var text string
var err error
src := mediaPathFor(m)
switch {
case docExts[m.FileExt]:
text, err = libreofficeExtract(src)
case m.FileExt == "pdf":
text, err = pdftotextExtract(src)
default:
// 视频/图片:仅标记,不提取
store.DB.Model(&m).Update("extracted", true)
return
}
if err != nil {
log.Printf("[提取失败] media_id=%d: %v", mediaID, err)
return
}
chunks := splitIntoChunks(text, 1000)
for i, chunk := range chunks {
if strings.TrimSpace(chunk) == "" {
continue
}
store.DB.Create(&model.KnowledgeChunk{
MediaFileID: &m.ID,
SourceType: m.FileExt,
SourceID: strconv.FormatUint(uint64(m.ID), 10),
ChunkIndex: i,
Content: chunk,
})
}
store.DB.Model(&m).Update("extracted", true)
log.Printf("[提取完成] media_id=%d chunks=%d", mediaID, len(chunks))
}
func libreofficeExtract(src string) (string, error) {
tmp, err := os.MkdirTemp("", "lo-convert")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
// 独立 UserInstallation:systemd 加固(ProtectHome=true)下 HOME 不可写,
// 避免 LibreOffice profile 初始化失败。
profileURI := "file://" + filepath.Join(tmp, "profile")
cmd := exec.Command(Cfg.LibreOfficeBin, "-env:UserInstallation="+profileURI,
"--headless", "--convert-to", "pdf", "--outdir", tmp, src)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("libreoffice 转换失败: %v %s", err, out)
}
base := strings.TrimSuffix(filepath.Base(src), filepath.Ext(src)) + ".pdf"
return pdftotextExtract(filepath.Join(tmp, base))
}
func pdftotextExtract(pdf string) (string, error) {
cmd := exec.Command(Cfg.PdftotextBin, "-enc", "UTF-8", pdf, "-")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("pdftotext 提取失败: %v", err)
}
return string(out), nil
}
func splitIntoChunks(text string, maxChars int) []string {
paragraphs := strings.Split(text, "\n\n")
var chunks []string
current := ""
for _, p := range paragraphs {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if len(current)+len(p) > maxChars {
if current != "" {
chunks = append(chunks, current)
}
current = p
} else if current == "" {
current = p
} else {
current += "\n\n" + p
}
}
if current != "" {
chunks = append(chunks, current)
}
return chunks
}
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
var validNoteItemTypes = map[string]bool{"company": true, "product": true, "course": true}
// ListNotes GET /api/notes?item_type=&item_id= —— 我的学习笔记
func ListNotes(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
q := store.DB.Where("user_id = ?", u.ID)
if t := c.Query("item_type"); t != "" {
q = q.Where("item_type = ?", t)
}
if s := c.Query("item_id"); s != "" {
if n, err := strconv.ParseUint(s, 10, 64); err == nil {
q = q.Where("item_id = ?", n)
}
}
var items []model.StudyNote
if err := q.Order("updated_at DESC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询笔记失败"))
return
}
web.OK(c, items)
}
// CreateNote POST /api/notes —— 新增学习笔记
func CreateNote(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
ItemType string `json:"item_type"`
ItemID uint `json:"item_id"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if !validNoteItemTypes[req.ItemType] {
web.Fail(c, web.NewBadRequest("item_type 必须为 company/product/course"))
return
}
if strings.TrimSpace(req.Content) == "" {
web.Fail(c, web.NewBadRequest("笔记内容不能为空"))
return
}
n := model.StudyNote{UserID: u.ID, ItemType: req.ItemType, ItemID: req.ItemID, Content: strings.TrimSpace(req.Content)}
if err := store.DB.Create(&n).Error; err != nil {
web.Fail(c, web.NewBadRequest("保存笔记失败"))
return
}
web.OK(c, n)
}
// UpdateNote PUT /api/notes/{id} —— 编辑学习笔记
func UpdateNote(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var n model.StudyNote
if err := store.DB.First(&n, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("笔记不存在"))
return
}
if n.UserID != u.ID {
web.Fail(c, web.NewForbiddenError("无权操作他人笔记"))
return
}
var req struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if strings.TrimSpace(req.Content) == "" {
web.Fail(c, web.NewBadRequest("笔记内容不能为空"))
return
}
n.Content = strings.TrimSpace(req.Content)
if err := store.DB.Save(&n).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新笔记失败"))
return
}
web.OK(c, n)
}
// DeleteNote DELETE /api/notes/{id} —— 删除学习笔记
func DeleteNote(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var n model.StudyNote
if err := store.DB.First(&n, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("笔记不存在"))
return
}
if n.UserID != u.ID {
web.Fail(c, web.NewForbiddenError("无权操作他人笔记"))
return
}
if err := store.DB.Delete(&n).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除笔记失败"))
return
}
web.OK(c, gin.H{"id": id})
}
@@ -0,0 +1,88 @@
package api
import (
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// notifyUser 给单个用户发站内通知(userID=0 忽略)。
func notifyUser(userID uint, ntype, title, content, link string) {
if userID == 0 {
return
}
store.DB.Create(&model.Notification{
UserID: userID, Type: ntype, Title: title, Content: content, Link: link,
})
}
// notifyAllEmployees 给所有启用员工发通知(如新正式考试发布)。
func notifyAllEmployees(ntype, title, content, link string) {
var users []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Select("id").Find(&users)
for _, u := range users {
notifyUser(u.ID, ntype, title, content, link)
}
}
// MyNotifications GET /api/notifications?unread_only=true —— 我的通知列表
func MyNotifications(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
q := store.DB.Model(&model.Notification{}).Where("user_id = ?", u.ID)
if c.Query("unread_only") == "true" {
q = q.Where("read = ?", false)
}
var items []model.Notification
q.Order("created_at DESC, id DESC").Limit(100).Find(&items)
web.OK(c, items)
}
// UnreadNotificationCount GET /api/notifications/unread-count —— 未读数(铃铛角标)
func UnreadNotificationCount(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var n int64
store.DB.Model(&model.Notification{}).Where("user_id = ? AND read = ?", u.ID, false).Count(&n)
web.OK(c, gin.H{"unread": n})
}
// MarkNotificationRead PUT /api/notifications/{id}/read —— 标记单条已读
func MarkNotificationRead(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var n model.Notification
if err := store.DB.First(&n, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("通知不存在"))
return
}
if n.UserID != u.ID {
web.Fail(c, web.NewForbiddenError("无权操作他人通知"))
return
}
store.DB.Model(&n).Update("read", true)
web.OK(c, gin.H{"id": n.ID, "read": true})
}
// MarkAllNotificationsRead PUT /api/notifications/read-all —— 全部标记已读
func MarkAllNotificationsRead(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
store.DB.Model(&model.Notification{}).Where("user_id = ? AND read = ?", u.ID, false).Update("read", true)
web.OK(c, gin.H{"read": true})
}
@@ -0,0 +1,90 @@
package api
import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// —— 积分规则(游戏化成长值,集中一处便于调整,见 docs/changelog.md 待确认项)——
const (
ptFirstCompany = 10 // 首次浏览公司介绍
ptFirstProduct = 2 // 首次浏览单个产品
ptFirstCourse = 5 // 首次浏览单门课程
ptSelfTestSubmit = 5 // 自测提交一次
ptFormalPass = 50 // 正式考试通过
ptMistakeResolved = 3 // 错题标记已掌握
)
// awardPoints 记录积分流水并累加到 user.learning_points(points<=0 直接忽略,避免负分刷分)。
func awardPoints(userID uint, eventType string, points int, refType string, refID uint) {
if userID == 0 || points <= 0 {
return
}
store.DB.Create(&model.PointEvent{
UserID: userID,
EventType: eventType,
Points: points,
RefType: refType,
RefID: refID,
})
store.DB.Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("learning_points", gorm.Expr("learning_points + ?", points))
}
// awardFirstView 首次浏览公司/产品/课程加分(由 RecordLearningProgress 幂等触发)。
func awardFirstView(userID uint, itemType string, itemID uint) {
switch itemType {
case "company":
awardPoints(userID, "first_company", ptFirstCompany, itemType, itemID)
case "product":
awardPoints(userID, "first_product", ptFirstProduct, itemType, itemID)
case "course":
awardPoints(userID, "first_course", ptFirstCourse, itemType, itemID)
}
}
// MyPoints GET /api/points/me —— 我的积分 + 最近积分流水
func MyPoints(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var total int
store.DB.Raw(`SELECT COALESCE(SUM(points), 0) FROM point_event WHERE user_id = ?`, u.ID).Scan(&total)
var events []model.PointEvent
store.DB.Where("user_id = ?", u.ID).Order("created_at DESC, id DESC").Limit(50).Find(&events)
web.OK(c, gin.H{"total": total, "events": events})
}
// Leaderboard GET /api/points/leaderboard —— 学习积分排行榜(全员,含我的名次)
func Leaderboard(c *gin.Context) {
u := middleware.CurrentUser(c)
var users []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").
Order("learning_points DESC, id ASC").Find(&users)
out := make([]gin.H, 0, len(users))
myRank := 0
for i, usr := range users {
out = append(out, gin.H{
"rank": i + 1,
"user_id": usr.ID,
"full_name": usr.FullName,
"department": usr.Department,
"learning_points": usr.LearningPoints,
"is_me": u != nil && usr.ID == u.ID,
})
if u != nil && usr.ID == u.ID {
myRank = i + 1
}
}
web.OK(c, gin.H{"items": out, "my_rank": myRank, "total": len(out), "updated_at": time.Now()})
}
@@ -0,0 +1,397 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ============ 岗位 CRUD(管理员) ============
// ListPositions GET /api/positions?status= —— 岗位列表
func ListPositions(c *gin.Context) {
q := store.DB.Model(&model.Position{})
switch st := c.Query("status"); st {
case "": // 默认仅 active
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Position
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询岗位失败"))
return
}
web.OK(c, items)
}
// CreatePosition POST /api/positions (admin)
func CreatePosition(c *gin.Context) {
var p model.Position
if err := c.ShouldBindJSON(&p); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if p.Code == "" || p.Name == "" {
web.Fail(c, web.NewBadRequest("编号、名称为必填"))
return
}
var count int64
store.DB.Model(&model.Position{}).Where("code = ?", p.Code).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("岗位编号已存在"))
return
}
if p.Status == "" {
p.Status = "active"
}
if err := store.DB.Create(&p).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建岗位失败"))
return
}
web.OK(c, p)
}
// UpdatePosition PUT /api/positions/{id} (admin)
func UpdatePosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Position
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req model.Position
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.Name == "" {
web.Fail(c, web.NewBadRequest("名称为必填"))
return
}
if req.Status == "" {
req.Status = "active"
}
if req.Code != "" && req.Code != p.Code {
var count int64
store.DB.Model(&model.Position{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("岗位编号已存在"))
return
}
p.Code = req.Code
}
p.Name = req.Name
p.Description = req.Description
p.Status = req.Status
if err := store.DB.Save(&p).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新岗位失败"))
return
}
web.OK(c, p)
}
// DeletePosition DELETE /api/positions/{id} (admin) —— 软删除(status=inactive)
func DeletePosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Position
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil {
web.Fail(c, web.NewBadRequest("停用岗位失败"))
return
}
web.OK(c, gin.H{"id": id, "status": "inactive"})
}
// ============ 岗位知识映射(管理员) ============
// positionKnowledgeReq 映射条目入参(与 model.PositionKnowledge 同构)
type positionKnowledgeReq struct {
Domain string `json:"domain"`
CourseID *uint `json:"course_id"`
ProductID *uint `json:"product_id"`
RequiredLevel string `json:"required_level"`
Weight float64 `json:"weight"`
IsMandatory bool `json:"is_mandatory"`
}
// ListPositionKnowledge GET /api/positions/{id}/knowledge —— 某岗位知识映射列表
func ListPositionKnowledge(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var items []model.PositionKnowledge
if err := store.DB.Where("position_id = ?", id).Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询岗位知识映射失败"))
return
}
web.OK(c, items)
}
// SavePositionKnowledge PUT /api/positions/{id}/knowledge —— 保存映射(整表覆盖)
func SavePositionKnowledge(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var pos model.Position
if err := store.DB.First(&pos, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req struct {
Items []positionKnowledgeReq `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
// 校验并规整映射条目
rows := make([]model.PositionKnowledge, 0, len(req.Items))
for _, it := range req.Items {
if !validDomains[it.Domain] {
web.Fail(c, web.NewBadRequest("知识域 domain 非法(仅 company/product/sales)"))
return
}
if it.RequiredLevel == "" {
it.RequiredLevel = model.LevelL1
}
if !model.ValidLevels[it.RequiredLevel] {
web.Fail(c, web.NewBadRequest("级别 required_level 非法(仅 L1/L2/L3/L4)"))
return
}
if it.Weight <= 0 {
it.Weight = 1
}
rows = append(rows, model.PositionKnowledge{
PositionID: id,
Domain: it.Domain,
CourseID: it.CourseID,
ProductID: it.ProductID,
RequiredLevel: it.RequiredLevel,
Weight: it.Weight,
IsMandatory: it.IsMandatory,
})
}
// 整表覆盖:先删旧,再批量插入
if err := store.DB.Where("position_id = ?", id).Delete(&model.PositionKnowledge{}).Error; err != nil {
web.Fail(c, web.NewBadRequest("清除旧映射失败"))
return
}
if len(rows) > 0 {
if err := store.DB.Create(&rows).Error; err != nil {
web.Fail(c, web.NewBadRequest("保存岗位知识映射失败"))
return
}
}
var out []model.PositionKnowledge
store.DB.Where("position_id = ?", id).Order("id ASC").Find(&out)
web.OK(c, out)
}
// ============ 用户设岗(管理员) ============
// SetUserPosition PUT /api/users/{id}/position —— {position_id: 5 | null}
func SetUserPosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var u model.User
if err := store.DB.First(&u, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("用户不存在"))
return
}
var req struct {
PositionID *uint `json:"position_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
posName := ""
if req.PositionID != nil {
var pos model.Position
if err := store.DB.First(&pos, *req.PositionID).Error; err != nil || pos.Status != "active" {
web.Fail(c, web.NewBadRequest("岗位不存在或已停用"))
return
}
posName = pos.Name
}
u.PositionID = req.PositionID
if err := store.DB.Save(&u).Error; err != nil {
web.Fail(c, web.NewBadRequest("设置用户岗位失败"))
return
}
if req.PositionID != nil {
notifyUser(u.ID, "position_set", "岗位已设置",
fmt.Sprintf("你的岗位已设置为「%s」,可在「我的岗位清单」查看应学内容", posName),
"/exam/my-position")
}
web.OK(c, gin.H{"id": u.ID, "position_id": u.PositionID})
}
// ============ 学员:我的岗位应学清单 ============
// MyPosition GET /api/my/position —— 我的岗位 + 应学范围(课程/产品/域)
func MyPosition(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil || u.PositionID == nil {
web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0})
return
}
var pos model.Position
if err := store.DB.First(&pos, *u.PositionID).Error; err != nil || pos.Status != "active" {
web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0})
return
}
var pks []model.PositionKnowledge
store.DB.Where("position_id = ?", pos.ID).Order("id ASC").Find(&pks)
// 批量解析课程/产品名称
courseIDs := make([]uint, 0, len(pks))
productIDs := make([]uint, 0, len(pks))
for _, pk := range pks {
if pk.CourseID != nil {
courseIDs = append(courseIDs, *pk.CourseID)
}
if pk.ProductID != nil {
productIDs = append(productIDs, *pk.ProductID)
}
}
courseName := map[uint]string{}
if len(courseIDs) > 0 {
var courses []model.Course
store.DB.Where("id IN ?", courseIDs).Find(&courses)
for _, c := range courses {
courseName[c.ID] = c.Name
}
}
productName := map[uint]string{}
if len(productIDs) > 0 {
var products []model.Product
store.DB.Where("id IN ?", productIDs).Find(&products)
for _, p := range products {
productName[p.ID] = p.Name
}
}
list := make([]gin.H, 0, len(pks))
for _, pk := range pks {
list = append(list, gin.H{
"id": pk.ID,
"domain": pk.Domain,
"course_id": pk.CourseID,
"product_id": pk.ProductID,
"course_name": nameOrDash(courseName, pk.CourseID),
"product_name": nameOrDash(productName, pk.ProductID),
"required_level": pk.RequiredLevel,
"weight": pk.Weight,
"is_mandatory": pk.IsMandatory,
})
}
web.OK(c, gin.H{
"position": pos,
"knowledge": list,
"count": len(list),
})
}
func nameOrDash(m map[uint]string, id *uint) string {
if id == nil {
return ""
}
if n, ok := m[*id]; ok {
return n
}
return ""
}
// ============ 岗位考试蓝图(管理员) ============
type blueprintReq struct {
Domain string `json:"domain"`
Type string `json:"type"`
Count int `json:"count"`
}
// ListPositionBlueprint GET /api/positions/{id}/blueprint —— 某岗位考试蓝图列表
func ListPositionBlueprint(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var items []model.PositionExamBlueprint
if err := store.DB.Where("position_id = ?", id).Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询岗位考试蓝图失败"))
return
}
web.OK(c, items)
}
// SavePositionBlueprint PUT /api/positions/{id}/blueprint —— 保存蓝图(整表覆盖)
func SavePositionBlueprint(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var pos model.Position
if err := store.DB.First(&pos, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req struct {
Items []blueprintReq `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
rows := make([]model.PositionExamBlueprint, 0, len(req.Items))
for _, it := range req.Items {
if !validQTypes[it.Type] {
web.Fail(c, web.NewBadRequest("题型 type 非法(仅 single/multiple/judge/essay)"))
return
}
if it.Domain != "" && !validDomains[it.Domain] {
web.Fail(c, web.NewBadRequest("知识域 domain 非法(仅 company/product/sales,或留空不限)"))
return
}
if it.Count <= 0 {
web.Fail(c, web.NewBadRequest("蓝图各条 count 必须大于 0"))
return
}
rows = append(rows, model.PositionExamBlueprint{
PositionID: id, Domain: it.Domain, Type: it.Type, Count: it.Count,
})
}
if err := store.DB.Where("position_id = ?", id).Delete(&model.PositionExamBlueprint{}).Error; err != nil {
web.Fail(c, web.NewBadRequest("清除旧蓝图失败"))
return
}
if len(rows) > 0 {
if err := store.DB.Create(&rows).Error; err != nil {
web.Fail(c, web.NewBadRequest("保存岗位考试蓝图失败"))
return
}
}
var out []model.PositionExamBlueprint
store.DB.Where("position_id = ?", id).Order("id ASC").Find(&out)
web.OK(c, out)
}
@@ -0,0 +1,207 @@
package api
import (
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ListProducts GET /api/products?category=&status=
func ListProducts(c *gin.Context) {
q := store.DB.Model(&model.Product{})
if cat := c.Query("category"); cat != "" {
q = q.Where("category = ?", cat)
}
switch st := c.Query("status"); st {
case "": // 默认仅 active(员工浏览)
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Product
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询产品失败"))
return
}
web.OK(c, items)
}
// GetProduct GET /api/products/{id}
func GetProduct(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Product
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("产品不存在"))
return
}
web.OK(c, p)
}
// CreateProduct POST /api/products (admin)
func CreateProduct(c *gin.Context) {
var p model.Product
if err := c.ShouldBindJSON(&p); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if p.Code == "" || p.Name == "" || p.Category == "" {
web.Fail(c, web.NewBadRequest("编号、名称、分类为必填"))
return
}
var count int64
store.DB.Model(&model.Product{}).Where("code = ?", p.Code).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("产品编号已存在"))
return
}
if p.Status == "" {
p.Status = "active"
}
if err := store.DB.Create(&p).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建产品失败"))
return
}
web.OK(c, p)
}
// UpdateProduct PUT /api/products/{id} (admin)
func UpdateProduct(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Product
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("产品不存在"))
return
}
var req model.Product
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.Name == "" || req.Category == "" {
web.Fail(c, web.NewBadRequest("名称、分类为必填"))
return
}
if req.Status == "" {
req.Status = "active"
}
if req.Code != "" && req.Code != p.Code {
var count int64
store.DB.Model(&model.Product{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("产品编号已存在"))
return
}
p.Code = req.Code
}
p.Name = req.Name
p.Category = req.Category
p.Tags = req.Tags
p.Description = req.Description
p.Pricing = req.Pricing
p.CommissionRecommend = req.CommissionRecommend
p.CommissionNegotiate = req.CommissionNegotiate
p.PublicCourseBonus = req.PublicCourseBonus
p.VersionRisk = req.VersionRisk
p.ReportRules = req.ReportRules
p.Status = req.Status
if err := store.DB.Save(&p).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新产品失败"))
return
}
web.OK(c, p)
}
// DeleteProduct DELETE /api/products/{id} (admin) —— 软删除(status=inactive)
func DeleteProduct(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Product
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("产品不存在"))
return
}
if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil {
web.Fail(c, web.NewBadRequest("停用产品失败"))
return
}
web.OK(c, gin.H{"id": id, "status": "inactive"})
}
// ImportProducts POST /api/products/import (admin) —— 按 code 幂等 upsert
func ImportProducts(c *gin.Context) {
var items []model.Product
if err := c.ShouldBindJSON(&items); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if len(items) == 0 {
web.Fail(c, web.NewBadRequest("导入数据为空"))
return
}
created, updated := 0, 0
for _, it := range items {
if it.Code == "" || it.Name == "" || it.Category == "" {
continue
}
if it.Status == "" {
it.Status = "active"
}
var existing model.Product
err := store.DB.Where("code = ?", it.Code).First(&existing).Error
if err != nil {
if store.DB.Create(&it).Error == nil {
created++
}
continue
}
// 合并:仅覆盖导入数据中非零的字段,避免零值抹除已有数据
if it.Name != "" {
existing.Name = it.Name
}
if it.Category != "" {
existing.Category = it.Category
}
if it.Tags != "" {
existing.Tags = it.Tags
}
if it.Description != "" {
existing.Description = it.Description
}
if it.Pricing != "" {
existing.Pricing = it.Pricing
}
if it.CommissionRecommend != "" {
existing.CommissionRecommend = it.CommissionRecommend
}
if it.CommissionNegotiate != "" {
existing.CommissionNegotiate = it.CommissionNegotiate
}
if it.PublicCourseBonus != "" {
existing.PublicCourseBonus = it.PublicCourseBonus
}
if it.VersionRisk != "" {
existing.VersionRisk = it.VersionRisk
}
if it.ReportRules != "" {
existing.ReportRules = it.ReportRules
}
existing.Status = it.Status
if store.DB.Save(&existing).Error == nil {
updated++
}
}
web.OK(c, gin.H{"created": created, "updated": updated, "total": len(items)})
}
@@ -0,0 +1,162 @@
package api
import (
"encoding/json"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// domainLabels 能力雷达维度(正式考试按域聚合的掌握度)。
var domainLabels = map[string]string{
"company": "公司认知",
"product": "产品知识",
"sales": "销售能力",
}
// domainOrder 雷达图固定排序。
var domainOrder = []string{"company", "product", "sales"}
// detailWrap 正式考试答题明细(与 ExamSubmit 持久化的 detail_json 结构对齐)。
type detailWrap struct {
Questions []struct {
QuestionID uint `json:"question_id"`
IsCorrect bool `json:"is_correct"`
} `json:"questions"`
}
// MyProfile GET /api/my/profile —— 学员学习档案:能力雷达 + 学习统计 + 成绩趋势 + 积分。
func MyProfile(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
// 学习进度(公司/产品/课程浏览)
var lps []model.LearningProgress
store.DB.Where("user_id = ?", u.ID).Find(&lps)
companyViewed := false
productViewed, courseViewed := 0, 0
for _, lp := range lps {
switch lp.ItemType {
case "company":
companyViewed = true
case "product":
productViewed++
case "course":
courseViewed++
}
}
// 错题统计
var mistakeTotal, mistakeResolved int64
store.DB.Model(&model.MistakeRecord{}).Where("user_id = ?", u.ID).Count(&mistakeTotal)
store.DB.Model(&model.MistakeRecord{}).Where("user_id = ? AND resolved = ?", u.ID, true).Count(&mistakeResolved)
// 自测次数(自测不落 exam_record,改由积分流水统计)
var selfTestCount int64
store.DB.Model(&model.PointEvent{}).Where("user_id = ? AND event_type = ?", u.ID, "self_test").Count(&selfTestCount)
// 正式考试记录 + 按域能力聚合
var recs []model.ExamRecord
store.DB.Where("user_id = ?", u.ID).Order("submitted_at ASC").Find(&recs)
// 题目 → 域映射(用于从答题明细反推各域掌握度)
var questions []model.Question
store.DB.Select("id", "domain").Find(&questions)
qDomain := map[uint]string{}
for _, q := range questions {
qDomain[q.ID] = q.Domain
}
type domainAgg struct {
Correct int `json:"correct"`
Total int `json:"total"`
}
perDomain := map[string]*domainAgg{}
formalPassed := 0
trend := make([]gin.H, 0, len(recs))
for _, r := range recs {
if r.Passed {
formalPassed++
}
trend = append(trend, gin.H{
"exam_name": r.ExamName,
"score": r.Score,
"total_score": r.TotalScore,
"passed": r.Passed,
"submitted_at": r.SubmittedAt,
})
var dw detailWrap
if err := json.Unmarshal([]byte(r.DetailJSON), &dw); err != nil {
continue
}
for _, qd := range dw.Questions {
domain := qDomain[qd.QuestionID]
if domain == "" {
continue
}
a := perDomain[domain]
if a == nil {
a = &domainAgg{}
perDomain[domain] = a
}
a.Total++
if qd.IsCorrect {
a.Correct++
}
}
}
domains := make([]gin.H, 0, len(domainOrder))
for _, d := range domainOrder {
a := perDomain[d]
if a == nil {
a = &domainAgg{}
}
mastery := 0.0
if a.Total > 0 {
mastery = round1(float64(a.Correct) * 100 / float64(a.Total))
}
domains = append(domains, gin.H{
"domain": d,
"label": domainLabels[d],
"correct": a.Correct,
"total": a.Total,
"mastery": mastery,
})
}
// 薄弱点:有作答记录中掌握度最低的域
weakDomain := ""
weakMastery := 100.0
for _, d := range domains {
if d["total"].(int) > 0 && d["mastery"].(float64) < weakMastery {
weakMastery = d["mastery"].(float64)
weakDomain = d["domain"].(string)
}
}
web.OK(c, gin.H{
"learning_points": u.LearningPoints,
"stats": gin.H{
"company_viewed": companyViewed,
"product_viewed": productViewed,
"course_viewed": courseViewed,
"self_test_count": selfTestCount,
"formal_count": len(recs),
"formal_passed": formalPassed,
"mistake_count": mistakeTotal,
"mistake_resolved": mistakeResolved,
},
"domains": domains,
"weak_domain": weakDomain,
"recent_scores": trend,
})
}
@@ -0,0 +1,179 @@
package api
import (
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/middleware"
)
// Cfg 全局配置,RegisterRoutes 时注入
var Cfg *config.Config
// RegisterRoutes 注册所有路由
func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
Cfg = cfg
// 素材静态预览(只读):仅公开 approved/ 子目录(已审批通过文件),
// pending/ 未审批文件不对外暴露,落实「审批前置」。
if err := os.MkdirAll(filepath.Join(cfg.KBDataDir, "approved"), 0o755); err == nil {
r.Static("/media", filepath.Join(cfg.KBDataDir, "approved"))
}
r.GET("/api/health", func(c *gin.Context) {
ver := "1.1.0"
if p, err := config.LoadPlatformConfig(); err == nil {
ver = p.Version
}
c.JSON(200, gin.H{"status": "ok", "service": "eaisalestrain-app", "version": ver})
})
auth := r.Group("/api/auth")
{
auth.POST("/login", Login)
auth.GET("/me", middleware.Auth(cfg), Me)
}
// 员工可读
r.GET("/api/company-train", middleware.Auth(cfg), GetCompanyTrain)
r.POST("/api/company-train/suggest-material", middleware.Auth(cfg), SuggestMaterial)
r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists)
r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), GetSpecialistByKey)
r.GET("/api/specialists/summary", middleware.Auth(cfg), SpecialistSummary)
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
r.GET("/api/products", middleware.Auth(cfg), ListProducts)
r.GET("/api/products/:id", middleware.Auth(cfg), GetProduct)
r.GET("/api/courses", middleware.Auth(cfg), ListCourses)
r.GET("/api/courses/:id", middleware.Auth(cfg), GetCourse)
// 学员端考试
r.GET("/api/exam/list", middleware.Auth(cfg), ExamList)
r.GET("/api/exam/cover", middleware.Auth(cfg), ExamCover)
r.POST("/api/exam/start", middleware.Auth(cfg), ExamStart)
r.POST("/api/exam/submit", middleware.Auth(cfg), ExamSubmit)
r.GET("/api/exam/record", middleware.Auth(cfg), ExamRecordList)
r.GET("/api/exam/record/:recordId", middleware.Auth(cfg), ExamRecordDetail)
r.GET("/api/exam/mistakes", middleware.Auth(cfg), MyMistakes)
r.PUT("/api/exam/mistakes/:id/resolve", middleware.Auth(cfg), ResolveMistake)
r.POST("/api/exam/mistakes/practice", middleware.Auth(cfg), MistakePractice)
r.GET("/api/exam/certificates", middleware.Auth(cfg), MyCertificates)
r.GET("/api/exam/certificates/:id", middleware.Auth(cfg), CertificateDetail)
// 学员:我的岗位应学清单 / 学习档案 / 积分与排行榜
r.GET("/api/my/position", middleware.Auth(cfg), MyPosition)
r.GET("/api/my/profile", middleware.Auth(cfg), MyProfile)
r.GET("/api/points/me", middleware.Auth(cfg), MyPoints)
r.GET("/api/points/leaderboard", middleware.Auth(cfg), Leaderboard)
// 站内消息通知
r.GET("/api/notifications", middleware.Auth(cfg), MyNotifications)
r.GET("/api/notifications/unread-count", middleware.Auth(cfg), UnreadNotificationCount)
r.PUT("/api/notifications/:id/read", middleware.Auth(cfg), MarkNotificationRead)
r.PUT("/api/notifications/read-all", middleware.Auth(cfg), MarkAllNotificationsRead)
// 素材媒体(员工/管理员均可上传、预览)
r.POST("/api/media/upload", middleware.Auth(cfg), Upload)
r.POST("/api/media/upload-init", middleware.Auth(cfg), UploadInit)
r.POST("/api/media/upload-chunk", middleware.Auth(cfg), UploadChunk)
r.POST("/api/media/upload-complete", middleware.Auth(cfg), UploadComplete)
r.GET("/api/media/preview/:mediaId", middleware.Auth(cfg), Preview)
r.GET("/api/media/status/:mediaId", middleware.Auth(cfg), MediaStatus)
r.GET("/api/knowledge/status/:sourceId", middleware.Auth(cfg), KnowledgeStatus)
// ── AI 对话(普通员工可访问,管理员访问)──
r.POST("/api/ai-chat/message", middleware.Auth(cfg), ChatMessage)
r.GET("/api/ai-chat/quick-actions", middleware.Auth(cfg), QuickActions)
r.POST("/api/ai-chat/quick-action", middleware.Auth(cfg), QuickAction)
r.GET("/api/ai/me", middleware.Auth(cfg), AIUsageMe)
r.GET("/api/ai/usage", middleware.Auth(cfg), AIUsage)
r.GET("/api/ai/routes/chat", ListChatRoutes)
r.GET("/api/ai/routes/embed", ListEmbedRoutes)
// 学习进度(员工上报 + 本人查询)
r.POST("/api/learning/progress", middleware.Auth(cfg), RecordLearningProgress)
r.GET("/api/learning/me", middleware.Auth(cfg), MyLearningProgress)
// 学习笔记(员工个人工具)
r.GET("/api/notes", middleware.Auth(cfg), ListNotes)
r.POST("/api/notes", middleware.Auth(cfg), CreateNote)
r.PUT("/api/notes/:id", middleware.Auth(cfg), UpdateNote)
r.DELETE("/api/notes/:id", middleware.Auth(cfg), DeleteNote)
// 管理员维护
admin := r.Group("/api")
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
{
admin.POST("/products", CreateProduct)
admin.PUT("/products/:id", UpdateProduct)
admin.DELETE("/products/:id", DeleteProduct)
admin.POST("/products/import", ImportProducts)
admin.POST("/courses", CreateCourse)
admin.PUT("/courses/:id", UpdateCourse)
admin.GET("/exam/questions", ListQuestions)
admin.POST("/exam/questions", CreateQuestion)
admin.PUT("/exam/questions/:id", UpdateQuestion)
admin.DELETE("/exam/questions/:id", DeleteQuestion)
admin.GET("/exam/papers", ListPapers)
admin.POST("/exam/papers", CreatePaper)
admin.PUT("/exam/papers/:id", UpdatePaper)
admin.DELETE("/exam/papers/:id", DeletePaper)
admin.GET("/media/audit-list", AuditList)
admin.POST("/media/audit/:mediaId", AuditMedia)
admin.POST("/knowledge/scan", KnowledgeScan)
admin.GET("/knowledge/audit-list", KnowledgeAuditList)
admin.POST("/knowledge/audit/:sourceId", KnowledgeAudit)
admin.GET("/knowledge/export", ExportKnowledge)
admin.GET("/positions", ListPositions)
admin.POST("/positions", CreatePosition)
admin.PUT("/positions/:id", UpdatePosition)
admin.DELETE("/positions/:id", DeletePosition)
admin.POST("/specialists", CreateSpecialist)
admin.PUT("/specialists/:id", UpdateSpecialist)
admin.DELETE("/specialists/:id", DeleteSpecialist)
admin.GET("/positions/:id/knowledge", ListPositionKnowledge)
admin.PUT("/positions/:id/knowledge", SavePositionKnowledge)
admin.GET("/positions/:id/blueprint", ListPositionBlueprint)
admin.PUT("/positions/:id/blueprint", SavePositionBlueprint)
admin.PUT("/users/:id/position", SetUserPosition)
admin.GET("/departments", ListDepartments)
admin.POST("/departments", CreateDepartment)
admin.PUT("/departments/:id", UpdateDepartment)
admin.DELETE("/departments/:id", DeleteDepartment)
admin.GET("/system/users", ListUsers)
admin.POST("/system/users", CreateUser)
admin.PUT("/system/users/:id", UpdateUser)
admin.GET("/system/users/:id/overview", UserOverview)
admin.GET("/system/exam-records", ListExamRecords)
admin.GET("/system/exam-records/export", ExportExamRecords)
admin.GET("/system/exam-records/:id", GetExamRecord)
admin.DELETE("/system/exam-records/:id", DeleteExamRecord)
admin.GET("/system/dashboard", AdminDashboard)
admin.GET("/system/exam-stats", ExamStats)
admin.GET("/system/exam-stats-by-position", PositionExamStats)
admin.GET("/system/department-stats", DepartmentStats)
admin.GET("/system/certificates", AdminCertificates)
admin.GET("/system/exam-coverage", ExamCoverage)
admin.GET("/system/learning-progress", AdminLearningProgress)
admin.GET("/system/config", GetConfig)
admin.PUT("/system/config", UpdateConfig)
admin.GET("/ai/config", GetAIConfig)
admin.PUT("/ai/config", PutAIConfig)
admin.POST("/ai/reload", ReloadAIConfig)
admin.GET("/ai/secrets-status", SecretsStatusHandler)
admin.GET("/ai/usage/users", AIUsageUsers)
}
}
@@ -0,0 +1,51 @@
package api
import (
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/web"
"github.com/gin-gonic/gin"
)
// routeItem 路由摘要(返回给前端选择器)
type routeItem struct {
ID string `json:"id"`
Provider string `json:"provider"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
Description string `json:"description"`
}
func toRouteItems(routes []*config.RouteConfig) []routeItem {
items := make([]routeItem, 0, len(routes))
for _, r := range routes {
items = append(items, routeItem{
ID: r.RouteID,
Provider: r.Provider,
Model: r.Model,
BaseURL: r.BaseURL,
Description: r.Description,
})
}
return items
}
// ListChatRoutes 返回可用 chat 路由列表(供前端选择器使用)
func ListChatRoutes(c *gin.Context) {
routes, err := config.GetRoutesByCategory("chat")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("路由加载失败: "+err.Error()))
return
}
web.OK(c, gin.H{"routes": toRouteItems(routes)})
}
// ListEmbedRoutes 返回可用 embedding 路由列表
func ListEmbedRoutes(c *gin.Context) {
routes, err := config.GetRoutesByCategory("embed")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("路由加载失败: "+err.Error()))
return
}
web.OK(c, gin.H{"routes": toRouteItems(routes)})
}
@@ -0,0 +1,342 @@
package api
import (
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ListSpecialists GET /api/specialists?tier=&state=&market_tag=
func ListSpecialists(c *gin.Context) {
q := store.DB.Model(&model.Specialist{})
if tier := c.Query("tier"); tier != "" {
q = q.Where("tier = ?", tier)
}
if marketTag := c.Query("market_tag"); marketTag != "" {
q = q.Where("market_tag = ?", marketTag)
}
user := middleware.CurrentUser(c)
isAdmin := user != nil && user.Role == "admin"
switch state := c.Query("state"); state {
case "":
q = q.Where("state = ?", "active")
case "all":
if !isAdmin {
q = q.Where("state = ?", "active")
}
default:
q = q.Where("state = ?", state)
}
var items []model.Specialist
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询专员目录失败"))
return
}
for i := range items {
store.EnsureSpecialistStructuredRecords(&items[i])
}
web.OK(c, items)
}
// GetSpecialistByKey GET /api/specialists/by-key/:key
func GetSpecialistByKey(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("专员 key 不能为空"))
return
}
q := store.DB.Model(&model.Specialist{}).Where("key = ?", key)
user := middleware.CurrentUser(c)
isAdmin := user != nil && user.Role == "admin"
if !isAdmin {
q = q.Where("state = ?", "active")
}
var item model.Specialist
if err := q.First(&item).Error; err != nil {
web.Fail(c, web.NewNotFoundError("专员不存在"))
return
}
store.EnsureSpecialistStructuredRecords(&item)
web.OK(c, item)
}
// SpecialistSummary GET /api/specialists/summary
func SpecialistSummary(c *gin.Context) {
type summary struct {
Total int64 `json:"total"`
Generic int64 `json:"generic"`
Industry int64 `json:"industry"`
DW int64 `json:"dw"`
ADW int64 `json:"adw"`
Upgradeable int64 `json:"upgradeable"`
Trial int64 `json:"trial"`
}
var resp summary
count := func(column string, value string) int64 {
var total int64
q := store.DB.Model(&model.Specialist{}).Where("state = ?", "active")
if column != "" {
q = q.Where(column+" = ?", value)
}
q.Count(&total)
return total
}
resp.Total = count("", "")
resp.Generic = count("tier", "generic")
resp.Industry = count("tier", "industry")
resp.DW = count("worker_type", "dw")
resp.ADW = count("worker_type", "adw")
resp.Upgradeable = count("market_tag", "可升级")
resp.Trial = count("market_tag", "试用")
web.OK(c, resp)
}
type specialistReq struct {
Key string `json:"key"`
Label string `json:"label"`
Tier string `json:"tier"`
WorkerType string `json:"worker_type"`
Route string `json:"route"`
Summary string `json:"summary"`
WorkStatus string `json:"work_status"`
RiskLabel string `json:"risk_label"`
Color string `json:"color"`
Stage string `json:"stage"`
Progress int `json:"progress"`
MarketTag string `json:"market_tag"`
Version string `json:"version"`
ConnectorScope string `json:"connector_scope"`
PermissionScope string `json:"permission_scope"`
ResourceBindings string `json:"resource_bindings"`
InfoSources string `json:"info_sources"`
BaseSkills string `json:"base_skills"`
AIAssistance string `json:"ai_assistance"`
GeneratedSkills string `json:"generated_skills"`
InputsRecordsJSON string `json:"inputs_records_json"`
LegacySourceRecordsJSON string `json:"source_records_json"`
PermissionRecordsJSON string `json:"permission_records_json"`
ActionRecordsJSON string `json:"action_records_json"`
ResultRecordsJSON string `json:"result_records_json"`
State string `json:"state"`
SortOrder int `json:"sort_order"`
}
func normalizeSpecialistReq(req *specialistReq) {
req.Key = strings.TrimSpace(req.Key)
req.Label = strings.TrimSpace(req.Label)
req.Tier = strings.TrimSpace(req.Tier)
req.WorkerType = strings.TrimSpace(req.WorkerType)
req.Route = strings.TrimSpace(req.Route)
req.Summary = strings.TrimSpace(req.Summary)
req.WorkStatus = strings.TrimSpace(req.WorkStatus)
req.RiskLabel = strings.TrimSpace(req.RiskLabel)
req.Color = strings.TrimSpace(req.Color)
req.Stage = strings.TrimSpace(req.Stage)
req.MarketTag = strings.TrimSpace(req.MarketTag)
req.Version = strings.TrimSpace(req.Version)
req.ConnectorScope = strings.TrimSpace(req.ConnectorScope)
req.PermissionScope = strings.TrimSpace(req.PermissionScope)
req.ResourceBindings = strings.TrimSpace(req.ResourceBindings)
req.InfoSources = strings.TrimSpace(req.InfoSources)
req.BaseSkills = strings.TrimSpace(req.BaseSkills)
req.AIAssistance = strings.TrimSpace(req.AIAssistance)
req.GeneratedSkills = strings.TrimSpace(req.GeneratedSkills)
req.InputsRecordsJSON = strings.TrimSpace(req.InputsRecordsJSON)
req.LegacySourceRecordsJSON = strings.TrimSpace(req.LegacySourceRecordsJSON)
if req.InputsRecordsJSON == "" {
req.InputsRecordsJSON = req.LegacySourceRecordsJSON
}
req.PermissionRecordsJSON = strings.TrimSpace(req.PermissionRecordsJSON)
req.ActionRecordsJSON = strings.TrimSpace(req.ActionRecordsJSON)
req.ResultRecordsJSON = strings.TrimSpace(req.ResultRecordsJSON)
req.State = strings.TrimSpace(req.State)
}
func validateSpecialistReq(req *specialistReq) *web.AppError {
normalizeSpecialistReq(req)
if req.Key == "" || req.Label == "" || req.Tier == "" || req.Route == "" {
return web.NewBadRequest("key、label、tier、route 为必填")
}
if req.Tier != "generic" && req.Tier != "industry" {
return web.NewBadRequest("tier 只能是 generic 或 industry")
}
if req.WorkerType == "" {
req.WorkerType = "dw"
}
if req.WorkerType != "dw" && req.WorkerType != "adw" {
return web.NewBadRequest("worker_type 只能是 dw 或 adw")
}
if req.State == "" {
req.State = "active"
}
if req.State != "active" && req.State != "inactive" {
return web.NewBadRequest("state 只能是 active 或 inactive")
}
if req.MarketTag == "" {
req.MarketTag = "已安装"
}
if req.Progress < 0 || req.Progress > 100 {
return web.NewBadRequest("progress 必须在 0 到 100 之间")
}
if !store.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) {
return web.NewBadRequest("inputs_records_json 必须是 JSON 数组")
}
if !store.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) {
return web.NewBadRequest("permission_records_json 必须是 JSON 数组")
}
if !store.ValidateStructuredRecordsJSON(req.ActionRecordsJSON) {
return web.NewBadRequest("action_records_json 必须是 JSON 数组")
}
if !store.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) {
return web.NewBadRequest("result_records_json 必须是 JSON 数组")
}
return nil
}
// CreateSpecialist POST /api/specialists (admin)
func CreateSpecialist(c *gin.Context) {
var req specialistReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateSpecialistReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
var count int64
store.DB.Model(&model.Specialist{}).Where("key = ?", req.Key).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("专员 key 已存在"))
return
}
item := model.Specialist{
Key: req.Key,
Label: req.Label,
Tier: req.Tier,
WorkerType: req.WorkerType,
Route: req.Route,
Summary: req.Summary,
WorkStatus: req.WorkStatus,
RiskLabel: req.RiskLabel,
Color: req.Color,
Stage: req.Stage,
Progress: req.Progress,
MarketTag: req.MarketTag,
Version: req.Version,
ConnectorScope: req.ConnectorScope,
PermissionScope: req.PermissionScope,
ResourceBindings: req.ResourceBindings,
InfoSources: req.InfoSources,
BaseSkills: req.BaseSkills,
AIAssistance: req.AIAssistance,
GeneratedSkills: req.GeneratedSkills,
InputsRecordsJSON: req.InputsRecordsJSON,
PermissionRecordsJSON: req.PermissionRecordsJSON,
ActionRecordsJSON: req.ActionRecordsJSON,
ResultRecordsJSON: req.ResultRecordsJSON,
State: req.State,
SortOrder: req.SortOrder,
}
store.EnsureSpecialistStructuredRecords(&item)
if err := store.DB.Create(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建专员失败"))
return
}
web.OK(c, item)
}
// UpdateSpecialist PUT /api/specialists/:id (admin)
func UpdateSpecialist(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.Specialist
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("专员不存在"))
return
}
var req specialistReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateSpecialistReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
var count int64
store.DB.Model(&model.Specialist{}).Where("key = ? AND id <> ?", req.Key, id).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("专员 key 已存在"))
return
}
item.Key = req.Key
item.Label = req.Label
item.Tier = req.Tier
item.WorkerType = req.WorkerType
item.Route = req.Route
item.Summary = req.Summary
item.WorkStatus = req.WorkStatus
item.RiskLabel = req.RiskLabel
item.Color = req.Color
item.Stage = req.Stage
item.Progress = req.Progress
item.MarketTag = req.MarketTag
item.Version = req.Version
item.ConnectorScope = req.ConnectorScope
item.PermissionScope = req.PermissionScope
item.ResourceBindings = req.ResourceBindings
item.InfoSources = req.InfoSources
item.BaseSkills = req.BaseSkills
item.AIAssistance = req.AIAssistance
item.GeneratedSkills = req.GeneratedSkills
item.InputsRecordsJSON = req.InputsRecordsJSON
item.PermissionRecordsJSON = req.PermissionRecordsJSON
item.ActionRecordsJSON = req.ActionRecordsJSON
item.ResultRecordsJSON = req.ResultRecordsJSON
item.State = req.State
item.SortOrder = req.SortOrder
store.EnsureSpecialistStructuredRecords(&item)
if err := store.DB.Save(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新专员失败"))
return
}
web.OK(c, item)
}
// DeleteSpecialist DELETE /api/specialists/:id (admin)
func DeleteSpecialist(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.Specialist
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("专员不存在"))
return
}
if err := store.DB.Delete(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除专员失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
+384
View File
@@ -0,0 +1,384 @@
package api
import (
"math"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// round1 保留 1 位小数
func round1(f float64) float64 {
return math.Round(f*10) / 10
}
// examSummary 成绩汇总(传入某集合的 exam_record)
func examSummary(recs []model.ExamRecord) gin.H {
s := gin.H{"total": 0, "passed": 0, "failed": 0, "pass_rate": 0.0, "avg_score": 0.0, "max_score": 0, "min_score": 0}
if len(recs) == 0 {
return s
}
passed, sum, maxS, minS := 0, 0, -1, 1000000
for _, r := range recs {
if r.Passed {
passed++
}
sum += r.Score
if r.Score > maxS {
maxS = r.Score
}
if r.Score < minS {
minS = r.Score
}
}
s["total"] = len(recs)
s["passed"] = passed
s["failed"] = len(recs) - passed
s["pass_rate"] = round1(float64(passed) * 100 / float64(len(recs)))
s["avg_score"] = round1(float64(sum) / float64(len(recs)))
s["max_score"] = maxS
s["min_score"] = minS
return s
}
// AdminDashboard GET /api/system/dashboard —— 管理员首页运营指标
func AdminDashboard(c *gin.Context) {
var employeeCount, adminCount, productCount, courseCount, questionCount, formalPaperCount int64
var pendingMedia, pendingKnowledge, recentUsers int64
store.DB.Model(&model.User{}).Where("role = ?", "employee").Count(&employeeCount)
store.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
store.DB.Model(&model.Product{}).Where("status = ?", "active").Count(&productCount)
store.DB.Model(&model.Course{}).Where("status = ?", "active").Count(&courseCount)
store.DB.Model(&model.Question{}).Where("status = ?", "active").Count(&questionCount)
store.DB.Model(&model.ExamPaper{}).Where("type = ? AND status = ?", "formal", "active").Count(&formalPaperCount)
store.DB.Model(&model.MediaFile{}).Where("status = ?", "pending").Count(&pendingMedia)
store.DB.Model(&model.KnowledgeSource{}).Where("audit_status = ?", "pending").Count(&pendingKnowledge)
store.DB.Model(&model.User{}).Where("created_at >= ?", time.Now().AddDate(0, 0, -7)).Count(&recentUsers)
// 近 7 天每日新增(首页迷你柱状图数据源)
now := time.Now()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6)
var newRows []struct {
Date string `gorm:"column:d"`
Count int64 `gorm:"column:c"`
}
store.DB.Raw(`SELECT strftime('%Y-%m-%d', created_at) AS d, COUNT(*) AS c
FROM user WHERE created_at >= ? GROUP BY d`, dayStart).Scan(&newRows)
newByDay := map[string]int64{}
for _, r := range newRows {
newByDay[r.Date] = r.Count
}
newUsers7d := make([]gin.H, 0, 7)
for i := 0; i < 7; i++ {
d := dayStart.AddDate(0, 0, i).Format("2006-01-02")
newUsers7d = append(newUsers7d, gin.H{"date": d, "count": newByDay[d]})
}
var recs []model.ExamRecord
store.DB.Find(&recs)
web.OK(c, gin.H{
"employee_count": employeeCount,
"admin_count": adminCount,
"product_count": productCount,
"course_count": courseCount,
"question_count": questionCount,
"formal_paper_count": formalPaperCount,
"pending_material_count": pendingMedia + pendingKnowledge,
"recent_new_users": recentUsers,
"new_users_7d": newUsers7d,
"exam": examSummary(recs),
})
}
// ExamStats GET /api/system/exam-stats —— 成绩统计(汇总 + 按考试 + 分数段)
func ExamStats(c *gin.Context) {
var recs []model.ExamRecord
store.DB.Order("submitted_at DESC").Find(&recs)
// 按 paper 聚合
var papers []model.ExamPaper
store.DB.Order("id ASC").Find(&papers)
paperName := map[uint]string{}
paperType := map[uint]string{}
for _, p := range papers {
paperName[p.ID] = p.Name
paperType[p.ID] = p.Type
}
type paperAgg struct {
PaperID uint `json:"paper_id"`
ExamName string `json:"exam_name"`
Type string `json:"type"`
TakenCount int `json:"taken_count"`
Passed int `json:"passed_count"`
PassRate float64 `json:"pass_rate"`
AvgScore float64 `json:"avg_score"`
}
pmap := map[uint]*paperAgg{}
for _, r := range recs {
a := pmap[r.PaperID]
if a == nil {
a = &paperAgg{PaperID: r.PaperID, ExamName: paperName[r.PaperID], Type: paperType[r.PaperID]}
if a.ExamName == "" {
a.ExamName = r.ExamName
}
pmap[r.PaperID] = a
}
a.TakenCount++
if r.Passed {
a.Passed++
}
}
papersOut := make([]paperAgg, 0, len(pmap))
for _, a := range pmap {
a.PassRate = round1(float64(a.Passed) * 100 / float64(a.TakenCount))
a.AvgScore = round1(avgScoreOf(recs, a.PaperID))
papersOut = append(papersOut, *a)
}
// 分数段分布
bands := []string{"0-59", "60-69", "70-79", "80-89", "90-100"}
bandCount := map[string]int{}
for _, b := range bands {
bandCount[b] = 0
}
for _, r := range recs {
bandCount[scoreBand(r.Score)]++
}
distribution := make([]gin.H, 0, len(bands))
for _, b := range bands {
distribution = append(distribution, gin.H{"band": b, "count": bandCount[b]})
}
web.OK(c, gin.H{
"summary": examSummary(recs),
"papers": papersOut,
"distribution": distribution,
})
}
func avgScoreOf(recs []model.ExamRecord, paperID uint) float64 {
sum, n := 0, 0
for _, r := range recs {
if r.PaperID == paperID {
sum += r.Score
n++
}
}
if n == 0 {
return 0
}
return float64(sum) / float64(n)
}
func scoreBand(score int) string {
switch {
case score < 60:
return "0-59"
case score < 70:
return "60-69"
case score < 80:
return "70-79"
case score < 90:
return "80-89"
default:
return "90-100"
}
}
// ExamCoverage GET /api/system/exam-coverage —— 正式考试覆盖度(缺考名单)
func ExamCoverage(c *gin.Context) {
var papers []model.ExamPaper
store.DB.Where("type = ? AND status = ?", "formal", "active").Order("id ASC").Find(&papers)
var employees []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Order("id ASC").Find(&employees)
var recs []model.ExamRecord
store.DB.Order("submitted_at DESC").Find(&recs)
// 每人每场取最新一次(正式考单次,取首条即可)
type key struct{ uid, pid uint }
lmap := map[key]model.ExamRecord{}
for _, r := range recs {
k := key{r.UserID, r.PaperID}
if _, ok := lmap[k]; !ok {
lmap[k] = r
}
}
papersOut := make([]gin.H, 0, len(papers))
for _, p := range papers {
var taken []gin.H
var pending []gin.H
for _, e := range employees {
r, ok := lmap[key{e.ID, p.ID}]
if ok {
taken = append(taken, gin.H{
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
"department": e.Department, "score": r.Score, "passed": r.Passed,
"submitted_at": r.SubmittedAt,
})
} else {
pending = append(pending, gin.H{
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
"department": e.Department,
})
}
}
papersOut = append(papersOut, gin.H{
"paper_id": p.ID,
"exam_name": p.Name,
"employee_count": len(employees),
"taken_count": len(taken),
"pending_count": len(pending),
"taken": taken,
"pending": pending,
})
}
web.OK(c, gin.H{"papers": papersOut})
}
// PositionExamStats GET /api/system/exam-stats-by-position —— 成绩按岗位聚合
func PositionExamStats(c *gin.Context) {
var positions []model.Position
store.DB.Where("status = ?", "active").Order("id ASC").Find(&positions)
var users []model.User
store.DB.Find(&users)
userPos := map[uint]uint{} // user_id -> position_id
posEmployee := map[uint]int{} // position_id -> 员工数
for _, u := range users {
if u.PositionID == nil {
continue
}
userPos[u.ID] = *u.PositionID
posEmployee[*u.PositionID]++
}
type posAgg struct {
PositionID uint `json:"position_id"`
PositionName string `json:"position_name"`
EmployeeCount int `json:"employee_count"`
TakenCount int `json:"taken_count"`
PassedCount int `json:"passed_count"`
PassRate float64 `json:"pass_rate"`
AvgScore float64 `json:"avg_score"`
}
agg := map[uint]*posAgg{}
for _, p := range positions {
agg[p.ID] = &posAgg{PositionID: p.ID, PositionName: p.Name, EmployeeCount: posEmployee[p.ID]}
}
scoreSum := map[uint]int{}
var recs []model.ExamRecord
store.DB.Find(&recs)
for _, r := range recs {
pid, ok := userPos[r.UserID]
if !ok {
continue
}
a := agg[pid]
if a == nil {
a = &posAgg{PositionID: pid}
agg[pid] = a
}
a.TakenCount++
if r.Passed {
a.PassedCount++
}
scoreSum[pid] += r.Score
}
out := make([]posAgg, 0, len(agg))
for _, p := range positions {
a := agg[p.ID]
if a == nil {
continue
}
if a.TakenCount > 0 {
a.PassRate = round1(float64(a.PassedCount) * 100 / float64(a.TakenCount))
a.AvgScore = round1(float64(scoreSum[p.ID]) / float64(a.TakenCount))
}
out = append(out, *a)
}
web.OK(c, gin.H{"items": out})
}
// UserOverview GET /api/system/users/{id}/overview —— 员工综合画像(考试 + AI 用量 + 学习进度)
func UserOverview(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var u model.User
if err := store.DB.First(&u, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("用户不存在"))
return
}
var recs []model.ExamRecord
store.DB.Where("user_id = ?", id).Order("submitted_at DESC").Find(&recs)
type aiAgg struct {
Calls int64 `json:"calls"`
Success int64 `json:"success"`
Credits int64 `json:"credits"`
}
var ai aiAgg
store.DB.Raw(`SELECT COUNT(*) AS calls,
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success,
COALESCE(SUM(credits_charged), 0) AS credits
FROM ai_call_log WHERE user_id = ?`, id).Scan(&ai)
var lps []model.LearningProgress
store.DB.Where("user_id = ?", id).Order("updated_at DESC").Find(&lps)
companyViewed := false
productCount, courseCount := 0, 0
var lastViewed *time.Time
for _, lp := range lps {
switch lp.ItemType {
case "company":
companyViewed = true
case "product":
productCount++
case "course":
courseCount++
}
if lastViewed == nil || lp.UpdatedAt.After(*lastViewed) {
t := lp.UpdatedAt
lastViewed = &t
}
}
web.OK(c, gin.H{
"user": gin.H{
"id": u.ID, "username": u.Username, "full_name": u.FullName,
"role": u.Role, "status": u.Status, "ai_points": u.AiPoints,
"department": u.Department, "position": u.Position, "hire_batch": u.HireBatch,
"created_at": u.CreatedAt,
},
"exam": gin.H{
"summary": examSummary(recs),
"records": recs,
},
"ai_usage": gin.H{
"total_calls": ai.Calls,
"success_calls": ai.Success,
"total_credits": ai.Credits,
"ai_points_left": u.AiPoints,
},
"learning": gin.H{
"company_viewed": companyViewed,
"product_count": productCount,
"course_count": courseCount,
"total_items": len(lps),
"last_viewed_at": lastViewed,
},
})
}
@@ -0,0 +1,348 @@
package api
import (
"encoding/csv"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ============ 用户管理(管理员) ============
// ListUsers GET /api/system/users —— 含考试统计(考试次数/通过数/最近成绩)
func ListUsers(c *gin.Context) {
var users []model.User
if err := store.DB.Order("id ASC").Find(&users).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询用户失败"))
return
}
var recs []model.ExamRecord
store.DB.Order("submitted_at DESC").Find(&recs)
type stat struct {
ExamCount int
PassedCount int
LatestScore *int
LatestPassed *bool
LatestExamName string
LatestSubmittedAt *time.Time
}
stats := map[uint]*stat{}
for _, r := range recs {
s := stats[r.UserID]
if s == nil {
s = &stat{}
stats[r.UserID] = s
}
s.ExamCount++
if r.Passed {
s.PassedCount++
}
if s.LatestSubmittedAt == nil {
sc, ps := r.Score, r.Passed
t := r.SubmittedAt
s.LatestScore, s.LatestPassed = &sc, &ps
s.LatestExamName = r.ExamName
s.LatestSubmittedAt = &t
}
}
out := make([]gin.H, 0, len(users))
for _, u := range users {
h := gin.H{
"id": u.ID, "username": u.Username, "full_name": u.FullName,
"role": u.Role, "status": u.Status, "ai_points": u.AiPoints,
"department": u.Department, "position": u.Position, "hire_batch": u.HireBatch,
"position_id": u.PositionID,
"created_at": u.CreatedAt,
"exam_count": 0, "passed_count": 0,
"latest_score": nil, "latest_passed": nil, "latest_exam_name": "", "latest_submitted_at": nil,
}
if s := stats[u.ID]; s != nil {
h["exam_count"] = s.ExamCount
h["passed_count"] = s.PassedCount
h["latest_score"] = s.LatestScore
h["latest_passed"] = s.LatestPassed
h["latest_exam_name"] = s.LatestExamName
h["latest_submitted_at"] = s.LatestSubmittedAt
}
out = append(out, h)
}
web.OK(c, out)
}
// CreateUser POST /api/system/users —— {username,password,full_name,role,department,position,hire_batch}
func CreateUser(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
FullName string `json:"full_name"`
Role string `json:"role"`
Department string `json:"department"`
Position string `json:"position"`
HireBatch string `json:"hire_batch"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" || req.FullName == "" {
web.Fail(c, web.NewBadRequest("username/password/full_name 必填"))
return
}
if req.Role != "admin" && req.Role != "employee" {
req.Role = "employee"
}
var count int64
store.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("用户名已存在"))
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
web.Fail(c, web.NewBadRequest("密码加密失败"))
return
}
u := model.User{
Username: req.Username, PasswordHash: hash, FullName: req.FullName,
Role: req.Role, Status: "active", AiPoints: defaultAiPoints(),
Department: req.Department, Position: req.Position, HireBatch: req.HireBatch,
}
if req.Role == "admin" {
u.AiPoints = 999999
}
if err := store.DB.Create(&u).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建用户失败"))
return
}
web.OK(c, u)
}
// UpdateUser PUT /api/system/users/{id} —— 编辑(可选改密/禁用/改角色)
func UpdateUser(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var u model.User
if err := store.DB.First(&u, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("用户不存在"))
return
}
var req struct {
FullName string `json:"full_name"`
Role string `json:"role"`
Status string `json:"status"`
Password string `json:"password"`
AiPoints *int `json:"ai_points"`
Department string `json:"department"`
Position string `json:"position"`
HireBatch string `json:"hire_batch"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.FullName != "" {
u.FullName = req.FullName
}
if req.Role == "admin" || req.Role == "employee" {
u.Role = req.Role
}
if req.Status == "active" || req.Status == "disabled" {
u.Status = req.Status
}
if req.Department != "" {
u.Department = req.Department
}
if req.Position != "" {
u.Position = req.Position
}
if req.HireBatch != "" {
u.HireBatch = req.HireBatch
}
if req.Password != "" {
hash, err := auth.HashPassword(req.Password)
if err != nil {
web.Fail(c, web.NewBadRequest("密码加密失败"))
return
}
u.PasswordHash = hash
}
if req.AiPoints != nil {
if *req.AiPoints < 0 {
web.Fail(c, web.NewBadRequest("ai_points 不能为负"))
return
}
u.AiPoints = *req.AiPoints
}
if err := store.DB.Save(&u).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新用户失败"))
return
}
web.OK(c, u)
}
// defaultAiPoints 读取新用户默认 AI 算力点(system_config.ai_points_default,缺省 100)
func defaultAiPoints() int {
var sc model.SystemConfig
if err := store.DB.Where("config_key = ?", "ai_points_default").First(&sc).Error; err == nil {
if v, err := strconv.Atoi(strings.TrimSpace(sc.ConfigValue)); err == nil {
return v
}
}
return 100
}
// ============ 成绩管理(管理员) ============
// ListExamRecords GET /api/system/exam-records?user_id=&paper_id=
func ListExamRecords(c *gin.Context) {
q := store.DB.Model(&model.ExamRecord{})
if uid := c.Query("user_id"); uid != "" {
q = q.Where("user_id = ?", uid)
}
if pid := c.Query("paper_id"); pid != "" {
q = q.Where("paper_id = ?", pid)
}
var items []model.ExamRecord
if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询成绩失败"))
return
}
web.OK(c, items)
}
// GetExamRecord GET /api/system/exam-records/{id} —— 详情
func GetExamRecord(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var rec model.ExamRecord
if err := store.DB.First(&rec, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("考试记录不存在"))
return
}
var detail any
_ = json.Unmarshal([]byte(rec.DetailJSON), &detail)
web.OK(c, gin.H{
"id": rec.ID, "user_id": rec.UserID, "paper_id": rec.PaperID, "exam_name": rec.ExamName,
"score": rec.Score, "total_score": rec.TotalScore, "pass_score": rec.PassScore, "passed": rec.Passed,
"correct_count": rec.CorrectCount, "wrong_count": rec.WrongCount,
"detail": detail, "submitted_at": rec.SubmittedAt,
})
}
// DeleteExamRecord DELETE /api/system/exam-records/{id} —— 删除成绩记录(用于重置正式考重考资格)
func DeleteExamRecord(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var rec model.ExamRecord
if err := store.DB.First(&rec, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("考试记录不存在"))
return
}
if err := store.DB.Delete(&rec).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除考试记录失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
// ExportExamRecords GET /api/system/exam-records/export —— 导出 CSV(支持 user_id/paper_id 过滤)
func ExportExamRecords(c *gin.Context) {
q := store.DB.Model(&model.ExamRecord{})
if uid := c.Query("user_id"); uid != "" {
q = q.Where("user_id = ?", uid)
}
if pid := c.Query("paper_id"); pid != "" {
q = q.Where("paper_id = ?", pid)
}
var items []model.ExamRecord
if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询成绩失败"))
return
}
// 用户名映射
var users []model.User
store.DB.Find(&users)
nameMap := map[uint]model.User{}
for _, u := range users {
nameMap[u.ID] = u
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="exam_records.csv"`)
c.Writer.WriteString("\xEF\xBB\xBF") // UTF-8 BOM,兼容 Excel
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"编号", "用户名", "姓名", "部门", "考试名称", "得分", "总分", "结果", "答对", "答错", "提交时间"})
for _, r := range items {
u := nameMap[r.UserID]
passed := "未通过"
if r.Passed {
passed = "通过"
}
_ = w.Write([]string{
strconv.FormatUint(uint64(r.ID), 10),
u.Username,
u.FullName,
u.Department,
r.ExamName,
strconv.Itoa(r.Score),
strconv.Itoa(r.TotalScore),
passed,
strconv.Itoa(r.CorrectCount),
strconv.Itoa(r.WrongCount),
r.SubmittedAt.Format("2006-01-02 15:04:05"),
})
}
w.Flush()
}
// ============ 系统参数配置(管理员) ============
// GetConfig GET /api/system/config —— 所有系统参数
func GetConfig(c *gin.Context) {
var items []model.SystemConfig
store.DB.Order("id ASC").Find(&items)
type cfgItem struct {
Key string `json:"config_key"`
Value string `json:"config_value"`
Description string `json:"description"`
}
out := make([]cfgItem, 0, len(items))
for _, it := range items {
out = append(out, cfgItem{Key: it.ConfigKey, Value: it.ConfigValue, Description: it.Description})
}
web.OK(c, gin.H{"configs": out})
}
// UpdateConfig PUT /api/system/config —— {configs: {key: value}}
func UpdateConfig(c *gin.Context) {
var req struct {
Configs map[string]string `json:"configs"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.Configs) == 0 {
web.Fail(c, web.NewBadRequest("configs 必填"))
return
}
for k, v := range req.Configs {
var sc model.SystemConfig
if err := store.DB.Where("config_key = ?", k).First(&sc).Error; err == nil {
sc.ConfigValue = v
store.DB.Save(&sc)
} else {
store.DB.Create(&model.SystemConfig{ConfigKey: k, ConfigValue: v})
}
}
web.OK(c, gin.H{"updated": len(req.Configs)})
}