init: 数字员工平台初始代码
包含前端(Vue3 + VueFlow 画布)、后端(Go)、文档体系。 - 工作台画布:节点拖放、连线模式、右键菜单、AI 助手 - 后端:连接器 API、专员种子数据 - 导航:左侧导航、工坊、市场、控制台
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
Reference in New Issue
Block a user