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

包含前端(Vue3 + VueFlow 画布)、后端(Go)、文档体系。
- 工作台画布:节点拖放、连线模式、右键菜单、AI 助手
- 后端:连接器 API、专员种子数据
- 导航:左侧导航、工坊、市场、控制台
This commit is contained in:
eaiadmin
2026-08-18 20:19:58 +08:00
commit 4e8817d768
239 changed files with 48631 additions and 0 deletions
@@ -0,0 +1,82 @@
package ai
import (
"time"
"gorm.io/gorm"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
)
// ──────────────────────────────────────────────
// 按用户算力点计费(对齐 pj034 router.py 的 compute_credits / log_ai_call)
// ──────────────────────────────────────────────
// AI 能力常量(pj034 AiCapability 的精简子集)
const (
CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
)
// CapabilityCredits 各能力扣点成本
var CapabilityCredits = map[string]int{
CapabilityAIChat: 1,
CapabilityTextGen: 1,
CapabilityEmbed: 0,
CapabilityEssayGrade: 0,
}
// ComputeCredits 扣点决策:仅「成功」才扣点;失败不扣。
func ComputeCredits(capability string, success bool) int {
if !success {
return 0
}
return CapabilityCredits[capability]
}
// LogEntry 一次 AI 调用的审计入参
type LogEntry struct {
UserID uint
Capability string
Provider string
RouteID string
Model string
TokensInput int
TokensOutput int
Success bool
ErrorMessage string
LatencyMs int
}
// LogCall 写 ai_call_log;成功且需扣点时从用户余额扣点。审计写入失败不阻断主流程。
func LogCall(e LogEntry) {
credits := ComputeCredits(e.Capability, e.Success)
status := "success"
if !e.Success {
status = "failed"
}
rec := model.AiCallLog{
UserID: e.UserID,
Capability: e.Capability,
Provider: e.Provider,
RouteID: e.RouteID,
Model: e.Model,
TokensInput: e.TokensInput,
TokensOutput: e.TokensOutput,
CreditsCharged: credits,
Status: status,
ErrorMessage: e.ErrorMessage,
LatencyMs: e.LatencyMs,
CreatedAt: time.Now(),
}
if err := store.DB.Create(&rec).Error; err != nil {
return
}
if credits > 0 {
store.DB.Model(&model.User{}).Where("id = ?", e.UserID).
UpdateColumn("ai_points", gorm.Expr("ai_points - ?", credits))
}
}
+397
View File
@@ -0,0 +1,397 @@
package ai
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
)
// ──────────────────────────────────────────────
// 基础类型
// ──────────────────────────────────────────────
// Message OpenAI 兼容消息
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatResult 非流式调用结果
type ChatResult struct {
Content string
Model string
Provider string
FinishReason string
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
}
// ──────────────────────────────────────────────
// OpenAI 兼容 HTTP 客户端
// ──────────────────────────────────────────────
// Client 低层 HTTP 客户端(基于 RouteConfig)
type Client struct {
baseURL string
apiKey string
model string
maxTokens int
temperature float64
hc *http.Client
}
// NewClient 从 RouteConfig 创建客户端
func NewClient(route *config.RouteConfig) *Client {
return &Client{
baseURL: strings.TrimRight(route.BaseURL, "/"),
apiKey: route.APIKey,
model: route.Model,
maxTokens: route.MaxTokens,
temperature: route.Temperature,
hc: &http.Client{Timeout: 120 * time.Second},
}
}
// NewClientLegacy 兼容旧接口(从 LLMConfig 创建)
func NewClientLegacy(cfg LLMConfig) *Client {
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
apiKey: cfg.APIKey,
model: cfg.Model,
maxTokens: cfg.MaxTokens,
temperature: cfg.Temperature,
hc: &http.Client{Timeout: 120 * time.Second},
}
}
func (c *Client) url(path string) string {
return c.baseURL + path
}
func (c *Client) headers() map[string]string {
h := map[string]string{"Content-Type": "application/json"}
if c.apiKey != "" {
h["Authorization"] = "Bearer " + c.apiKey
}
return h
}
func (c *Client) post(path string, body any) (*http.Response, error) {
j, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.url(path), bytes.NewReader(j))
if err != nil {
return nil, err
}
for k, v := range c.headers() {
req.Header.Set(k, v)
}
return c.hc.Do(req)
}
// ──────────────────────────────────────────────
// 非流式调用
// ──────────────────────────────────────────────
// Generate 非流式对话,返回完整正文
func (c *Client) Generate(messages []Message) (string, error) {
resp, err := c.post("/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": false,
"temperature": c.temperature,
"max_tokens": c.maxTokens,
})
if err != nil {
return "", fmt.Errorf("LLM 服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取 LLM 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("LLM 响应解析失败: %w", err)
}
if len(out.Choices) == 0 || strings.TrimSpace(out.Choices[0].Message.Content) == "" {
return "", fmt.Errorf("LLM 返回空正文")
}
return out.Choices[0].Message.Content, nil
}
// GenerateFull 非流式调用,返回完整 ChatResult(含 usage)
func (c *Client) GenerateFull(messages []Message) (*ChatResult, error) {
resp, err := c.post("/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": false,
"temperature": c.temperature,
"max_tokens": c.maxTokens,
})
if err != nil {
return nil, fmt.Errorf("LLM 服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 LLM 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
}
var raw struct {
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("LLM 响应解析失败: %w", err)
}
if len(raw.Choices) == 0 || strings.TrimSpace(raw.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("LLM 返回空正文")
}
result := &ChatResult{
Content: raw.Choices[0].Message.Content,
Model: raw.Model,
FinishReason: raw.Choices[0].FinishReason,
}
if raw.Usage != nil {
result.Usage.PromptTokens = raw.Usage.PromptTokens
result.Usage.CompletionTokens = raw.Usage.CompletionTokens
result.Usage.TotalTokens = raw.Usage.TotalTokens
}
return result, nil
}
// ──────────────────────────────────────────────
// 流式调用
// ──────────────────────────────────────────────
// GenerateStream SSE 流式调用,逐 chunk 回调
func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error {
resp, err := c.post("/chat/completions", map[string]any{
"model": c.model,
"messages": messages,
"stream": true,
"temperature": c.temperature,
"max_tokens": c.maxTokens,
"stream_options": map[string]any{"include_usage": true},
})
if err != nil {
return fmt.Errorf("LLM 服务不可达: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "[DONE]" {
break
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
onChunk(chunk.Choices[0].Delta.Content)
}
}
return scanner.Err()
}
// ──────────────────────────────────────────────
// Embedding
// ──────────────────────────────────────────────
// Embed 批量向量化
func (c *Client) Embed(inputs []string) ([][]float64, error) {
resp, err := c.post("/embeddings", map[string]any{
"model": c.model, "input": inputs,
})
if err != nil {
return nil, fmt.Errorf("embedding 服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 embedding 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("embedding 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
}
var out struct {
Data []struct {
Embedding []float64 `json:"embedding"`
} `json:"data"`
}
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("embedding 响应解析失败: %w", err)
}
res := make([][]float64, len(out.Data))
for i, d := range out.Data {
res[i] = d.Embedding
}
return res, nil
}
// ──────────────────────────────────────────────
// Fallback 链调用(参考 pj034 chat_completion_with_fallback)
// ──────────────────────────────────────────────
// GenerateWithFallback 依次尝试主路由 + 回退链,首个成功返回
func GenerateWithFallback(primary *config.RouteConfig, messages []Message) (string, error) {
// 构建路由链
chain := []*config.RouteConfig{primary}
fallbacks, err := config.GetFallbackRoutes(primary.RouteID)
if err == nil && len(fallbacks) > 0 {
chain = append(chain, fallbacks...)
}
var lastErr error
for _, route := range chain {
client := NewClient(route)
content, err := client.Generate(messages)
if err == nil {
return content, nil
}
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
}
return "", fmt.Errorf("所有路由均失败: %w", lastErr)
}
// GenerateFullWithFallback 非流式 + 回退链:返回结果与「实际命中」的路由(用于审计)
func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (*ChatResult, *config.RouteConfig, error) {
chain := []*config.RouteConfig{primary}
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 {
chain = append(chain, fallbacks...)
}
var lastErr error
for _, route := range chain {
client := NewClient(route)
res, err := client.GenerateFull(messages)
if err == nil {
res.Provider = route.Provider
return res, route, nil
}
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
}
return nil, nil, fmt.Errorf("所有路由均失败: %w", lastErr)
}
// ──────────────────────────────────────────────
// 配置解析(兼容旧接口)
// ──────────────────────────────────────────────
// LLMConfig 简单的 LLM 连接配置(旧版,逐步淘汰)
type LLMConfig struct {
BaseURL string
APIKey string
Model string
EmbedModel string
MaxTokens int
Temperature float64
}
// ResolveLLM 旧版:从 DB/环境 解析 LLM 配置
func ResolveLLM(cfg *config.Config) (LLMConfig, bool) {
get := func(key, def string) string {
var sc model.SystemConfig
if err := store.DB.Where("config_key = ?", key).First(&sc).Error; err == nil && strings.TrimSpace(sc.ConfigValue) != "" {
return sc.ConfigValue
}
return def
}
baseURL := get("llm_base_url", cfg.LLMBaseURL)
apiKey := get("llm_api_key", cfg.LLMAPIKey)
modelName := get("llm_model", cfg.LLMModel)
embedModel := get("embed_model", cfg.EmbedModel)
// DB 空时降级到 JSON secrets(flat 格式,pj034 兼容)
if baseURL == "" || apiKey == "" {
if baseURL == "" {
baseURL = config.GetProviderBaseURL("ollama")
}
if apiKey == "" {
apiKey = config.GetProviderAPIKey("ollama")
}
}
c := LLMConfig{
BaseURL: baseURL,
APIKey: apiKey,
Model: modelName,
EmbedModel: embedModel,
MaxTokens: 2048,
Temperature: 0.7,
}
if c.BaseURL == "" || c.Model == "" {
return c, false
}
return c, true
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "..."
}
@@ -0,0 +1,163 @@
package ai
import (
"math"
"sort"
"strings"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
)
// Retrieve 混合检索:向量 brute-force 余弦 + 关键词兜底,去重合并取 topK
func Retrieve(cfg *config.Config, query string, topK int) []string {
if topK <= 0 {
topK = 5
}
var vectorResults []string
if route, err := config.GetRoute("embed_gen"); err == nil {
if v, err := vectorRetrieve(route, query, topK); err == nil {
vectorResults = v
}
}
keywordResults := keywordRetrieve(query, topK*2)
seen := map[string]bool{}
out := make([]string, 0, topK)
for _, s := range append(vectorResults, keywordResults...) {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
if len(out) >= topK {
break
}
}
return out
}
// vectorRetrieve 向量检索:query + 所有 chunk 一次批量 embedding,brute-force 余弦 topK
func vectorRetrieve(route *config.RouteConfig, query string, topK int) ([]string, error) {
var chunks []model.KnowledgeChunk
store.DB.Order("id ASC").Find(&chunks)
if len(chunks) == 0 {
return nil, nil
}
client := NewClient(route)
inputs := make([]string, 0, len(chunks)+1)
inputs = append(inputs, query)
for _, ch := range chunks {
inputs = append(inputs, ch.Content)
}
vecs, err := client.Embed(inputs)
if err != nil || len(vecs) != len(inputs) {
if err != nil {
return nil, err
}
return nil, nil
}
qv := vecs[0]
type scored struct {
idx int
sim float64
}
ss := make([]scored, 0, len(chunks))
for i := 1; i < len(vecs); i++ {
ss = append(ss, scored{i - 1, cosine(qv, vecs[i])})
}
sort.Slice(ss, func(a, b int) bool { return ss[a].sim > ss[b].sim })
seen := map[string]bool{}
out := make([]string, 0, topK)
for _, s := range ss {
content := chunks[s.idx].Content
if seen[content] {
continue
}
seen[content] = true
out = append(out, content)
if len(out) >= topK {
break
}
}
return out, nil
}
// keywordRetrieve 关键词兜底:term 命中数排序
func keywordRetrieve(query string, topK int) []string {
terms := splitTerms(query)
var chunks []model.KnowledgeChunk
store.DB.Order("id ASC").Find(&chunks)
type scored struct {
content string
score int
}
var ss []scored
for _, ch := range chunks {
s := 0
for _, t := range terms {
if strings.Contains(ch.Content, t) {
s++
}
}
if s > 0 {
ss = append(ss, scored{ch.Content, s})
}
}
sort.Slice(ss, func(a, b int) bool { return ss[a].score > ss[b].score })
seen := map[string]bool{}
out := make([]string, 0, topK)
for _, s := range ss {
if seen[s.content] {
continue
}
seen[s.content] = true
out = append(out, s.content)
if len(out) >= topK {
break
}
}
return out
}
func splitTerms(q string) []string {
f := func(r rune) bool {
switch r {
case ' ', ',', '。', '?', '!', '、', ',', '.', '?', '!', ':', ':', ';', ';':
return true
}
return false
}
terms := strings.FieldsFunc(q, f)
var out []string
for _, t := range terms {
if len([]rune(t)) >= 2 {
out = append(out, t)
}
}
if len(out) == 0 {
out = []string{q}
}
return out
}
func cosine(a, b []float64) float64 {
if len(a) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
@@ -0,0 +1,42 @@
package ai
import (
"reflect"
"testing"
)
func TestSplitTerms(t *testing.T) {
got := splitTerms("这是 测试 内容")
want := []string{"这是", "测试", "内容"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitTerms = %v want %v", got, want)
}
// 单字词被过滤
got = splitTerms("我 喜欢 它")
if !reflect.DeepEqual(got, []string{"喜欢"}) {
t.Errorf("splitTerms single-char = %v", got)
}
// 全部被过滤时回退为整句
got = splitTerms("我 你 他")
if !reflect.DeepEqual(got, []string{"我 你 他"}) {
t.Errorf("splitTerms fallback = %v", got)
}
}
func TestCosine(t *testing.T) {
if got := cosine([]float64{1, 0}, []float64{1, 0}); got != 1 {
t.Errorf("cosine identical = %v", got)
}
if got := cosine([]float64{1, 0}, []float64{0, 1}); got != 0 {
t.Errorf("cosine orthogonal = %v", got)
}
if got := cosine([]float64{1}, []float64{1, 0}); got != 0 {
t.Errorf("cosine length-mismatch = %v", got)
}
if got := cosine([]float64{}, []float64{}); got != 0 {
t.Errorf("cosine empty = %v", got)
}
if got := cosine([]float64{0, 0}, []float64{1, 1}); got != 0 {
t.Errorf("cosine zero-norm = %v", got)
}
}
@@ -0,0 +1,63 @@
package api
import (
"encoding/json"
"io"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/web"
)
// ──────────────────────────────────────────────
// AI 配置管理(管理员)—— 读/写 ai_config.json + 热重载 + 密钥状态
// ──────────────────────────────────────────────
// GetAIConfig GET /api/ai/config —— 读完整 ai_config.json(返回 JSON 对象)
func GetAIConfig(c *gin.Context) {
raw, err := config.GetAIConfigRaw()
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("读取 ai_config.json 失败: "+err.Error()))
return
}
var obj any
if err := json.Unmarshal(raw, &obj); err != nil {
web.Fail(c, web.NewBadRequest("ai_config.json 解析失败: "+err.Error()))
return
}
web.OK(c, obj)
}
// PutAIConfig PUT /api/ai/config —— 校验并写回 ai_config.json(原子写 + 清缓存热生效)
func PutAIConfig(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
web.Fail(c, web.NewBadRequest("读取请求体失败"))
return
}
if len(raw) == 0 {
web.Fail(c, web.NewBadRequest("请求体为空"))
return
}
if err := config.SaveAIConfig(raw); err != nil {
web.Fail(c, web.NewBadRequest(err.Error()))
return
}
web.OK(c, gin.H{"saved": true})
}
// ReloadAIConfig POST /api/ai/reload —— 热重载(清缓存,无需重启)
func ReloadAIConfig(c *gin.Context) {
config.ResetCache()
if _, err := config.LoadAIConfig(true); err != nil {
web.Fail(c, web.NewLLMNotConfigured("重载后配置校验失败: "+err.Error()))
return
}
web.OK(c, gin.H{"reloaded": true})
}
// SecretsStatusHandler GET /api/ai/secrets-status —— 各 provider 密钥是否已配置(不回显明文)
func SecretsStatusHandler(c *gin.Context) {
web.OK(c, gin.H{"providers": config.SecretsStatus()})
}
@@ -0,0 +1,209 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/web"
)
// checkBalance 调用前校验用户算力点余额(不扣点的能力直接放行)
func checkBalance(c *gin.Context, user *model.User, capability string) bool {
if ai.ComputeCredits(capability, true) <= 0 {
return true
}
if user.AiPoints <= 0 {
web.Fail(c, web.NewPointsExhausted("AI 点数不足,请联系管理员充值"))
return false
}
return true
}
// ChatMessage POST /api/ai-chat/message —— SSE 流式对话(每轮扣点 + 审计)
func ChatMessage(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Message string `json:"message"`
Context map[string]any `json:"context"`
History []ai.Message `json:"history"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
web.Fail(c, web.NewBadRequest("message 必填"))
return
}
if !checkBalance(c, user, ai.CapabilityAIChat) {
return
}
route, err := config.GetRoute("path_coach")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error()))
return
}
knowledge := ai.Retrieve(Cfg, req.Message, 5)
messages := []ai.Message{{Role: "system", Content: buildSystemPrompt(req.Context, knowledge)}}
messages = append(messages, req.History...)
messages = append(messages, ai.Message{Role: "user", Content: req.Message})
client := ai.NewClient(route)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
web.Fail(c, web.NewLLMError("SSE flush 不支持"))
return
}
writeEvent := func(obj any) {
b, _ := json.Marshal(obj)
fmt.Fprintf(c.Writer, "data: %s\n\n", b)
flusher.Flush()
}
start := time.Now()
if err := client.GenerateStream(messages, func(chunk string) {
writeEvent(gin.H{"type": "text", "content": chunk})
}); err != nil {
writeEvent(gin.H{"type": "error", "message": err.Error()})
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
return
}
writeEvent(gin.H{"type": "done"})
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: true,
LatencyMs: int(time.Since(start).Milliseconds()),
})
}
func buildSystemPrompt(ctx map[string]any, knowledge []string) string {
ctxJSON, _ := json.Marshal(ctx)
kc := strings.Join(knowledge, "\n\n")
return fmt.Sprintf(`你是博昇内部培训平台的 AI 助教 PathCoach。
职责:
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关问题
2. 严格依赖已审批知识库内容回答
3. 知识库未找到相关资料时,明确回答「未找到相关资料」,不得臆测
禁止:
1. 禁止闲聊
2. 禁止编造数据
3. 禁止回答超出业务范围的问题
4. 禁止泄露敏感信息
当前页面上下文:
%s
知识库相关片段:
%s`, string(ctxJSON), kc)
}
// QuickActions GET /api/ai-chat/quick-actions —— 3 个快捷按钮
func QuickActions(c *gin.Context) {
web.OK(c, gin.H{"actions": []gin.H{
{"id": "scenario", "label": "客户情景演练"},
{"id": "commission", "label": "查询佣金/规则"},
{"id": "compare", "label": "产品对比"},
}})
}
// quickActionTask 每个快捷动作的检索词与任务说明
var quickActionTask = map[string]struct {
query string
task string
}{
"commission": {
query: "产品佣金 佣金规则 回款 结算 公开课奖励",
task: "请基于下方知识库,汇总相关产品的佣金比例、结算规则与奖励规则;知识库未覆盖的部分明确说明,不得臆测。",
},
"compare": {
query: "产品对比 定位 收费 适用场景",
task: "请基于下方知识库,对比相关产品的定位、收费与适用场景,突出差异。",
},
"scenario": {
query: "销售话术 销售流程 异议处理 情景演练",
task: "请基于下方知识库,扮演销售进行客户情景演练:推介相关产品或课程,并演示异议处理话术。",
},
}
// QuickAction POST /api/ai-chat/quick-action —— 非流式快捷动作(扣点 + 回退链 + 审计)
func QuickAction(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
ActionID string `json:"action_id"`
Params map[string]any `json:"params"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.ActionID == "" {
web.Fail(c, web.NewBadRequest("action_id 必填"))
return
}
if !checkBalance(c, user, ai.CapabilityTextGen) {
return
}
route, err := config.GetRoute("title_gen")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error()))
return
}
task, ok := quickActionTask[req.ActionID]
if !ok {
web.Fail(c, web.NewBadRequest("未知快捷动作"))
return
}
knowledge := ai.Retrieve(Cfg, task.query, 5)
systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task
messages := []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: task.query},
}
start := time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
web.Fail(c, web.NewLLMError("LLM 调用失败:"+err.Error()))
return
}
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: usedRoute.Provider,
RouteID: usedRoute.RouteID, Model: usedRoute.Model, Success: true,
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
LatencyMs: int(time.Since(start).Milliseconds()),
})
out := gin.H{"result": result.Content}
if req.ActionID == "scenario" {
out["mode"] = "scenario"
}
web.OK(c, out)
}
@@ -0,0 +1,166 @@
package api
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ──────────────────────────────────────────────
// 算力点用量查询(管理员 = 全量;员工 = 本人)
// ──────────────────────────────────────────────
// AIUsage GET /api/ai/usage?days=30&group_by=month|capability|provider
func AIUsage(c *gin.Context) {
user := middleware.CurrentUser(c)
days := parseIntDefault(c.Query("days"), 30)
if days <= 0 {
days = 30
}
groupBy := c.DefaultQuery("group_by", "month")
switch groupBy {
case "month", "day", "capability", "provider":
default:
groupBy = "month"
}
// 员工仅能看本人
userFilter := ""
args := []any{daysAgo(days)}
if user != nil && user.Role != "admin" {
userFilter = " AND user_id = ?"
args = append(args, user.ID)
}
var summary struct {
TotalCalls int64 `json:"total_calls"`
SuccessCalls int64 `json:"success_calls"`
FailedCalls int64 `json:"failed_calls"`
TotalCredits int64 `json:"total_credits"`
}
store.DB.Raw(`
SELECT COUNT(*) AS total_calls,
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END),0) AS success_calls,
COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END),0) AS failed_calls,
COALESCE(SUM(credits_charged),0) AS total_credits
FROM ai_call_log
WHERE created_at >= ?`+userFilter, args...).Scan(&summary)
// 分组字段(SQLite 下按月走 strftime,避免依赖 date_trunc)
var groupExpr, orderExpr string
switch groupBy {
case "capability":
groupExpr, orderExpr = "capability", "capability"
case "provider":
groupExpr, orderExpr = "COALESCE(provider,'')", "provider"
case "day":
groupExpr, orderExpr = "strftime('%Y-%m-%d', created_at)", "bucket"
default:
groupExpr, orderExpr = "strftime('%Y-%m', created_at)", "bucket"
}
type bucket struct {
Bucket string `json:"bucket"`
TotalCalls int64 `json:"total_calls"`
SuccessCalls int64 `json:"success_calls"`
FailedCalls int64 `json:"failed_calls"`
TotalCredits int64 `json:"total_credits"`
}
buckets := []bucket{}
store.DB.Raw(`
SELECT `+groupExpr+` AS bucket,
COUNT(*) AS total_calls,
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END),0) AS success_calls,
COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END),0) AS failed_calls,
COALESCE(SUM(credits_charged),0) AS total_credits
FROM ai_call_log
WHERE created_at >= ?`+userFilter+`
GROUP BY `+groupExpr+`
ORDER BY `+orderExpr, args...).Scan(&buckets)
web.OK(c, gin.H{"summary": summary, "buckets": buckets, "group_by": groupBy, "days": days})
}
// AIUsageUsers GET /api/ai/usage/users —— 管理员:按用户聚合用量 + 剩余点数
func AIUsageUsers(c *gin.Context) {
var users []model.User
store.DB.Order("id ASC").Find(&users)
type agg struct {
UserID uint `json:"user_id"`
TotalCalls int64 `json:"total_calls"`
TotalCredits int64 `json:"total_credits"`
}
var aggs []agg
store.DB.Raw(`SELECT user_id, COUNT(*) AS total_calls, COALESCE(SUM(credits_charged),0) AS total_credits FROM ai_call_log GROUP BY user_id`).Scan(&aggs)
aggMap := make(map[uint]agg, len(aggs))
for _, a := range aggs {
aggMap[a.UserID] = a
}
type userUsage struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
FullName string `json:"full_name"`
Role string `json:"role"`
AiPoints int `json:"ai_points"`
TotalCalls int64 `json:"total_calls"`
TotalCredits int64 `json:"total_credits"`
}
out := make([]userUsage, 0, len(users))
for _, u := range users {
a := aggMap[u.ID]
out = append(out, userUsage{
UserID: u.ID,
Username: u.Username,
FullName: u.FullName,
Role: u.Role,
AiPoints: u.AiPoints,
TotalCalls: a.TotalCalls,
TotalCredits: a.TotalCredits,
})
}
web.OK(c, gin.H{"users": out})
}
// AIUsageMe GET /api/ai/me —— 本人剩余点数 + 用量(PathCoach 面板数据源)
func AIUsageMe(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var totalUsed int64
store.DB.Raw(`SELECT COALESCE(SUM(credits_charged),0) FROM ai_call_log WHERE user_id = ?`, user.ID).Scan(&totalUsed)
var recent struct {
Used int64
Calls int64
}
store.DB.Raw(`SELECT COALESCE(SUM(credits_charged),0) AS used, COUNT(*) AS calls FROM ai_call_log WHERE user_id = ? AND created_at >= ?`, user.ID, daysAgo(30)).Scan(&recent)
web.OK(c, gin.H{
"ai_points": user.AiPoints,
"total_used": totalUsed,
"recent_used": recent.Used,
"recent_calls": recent.Calls,
})
}
// parseIntDefault 解析 int 查询参数,非法时返回默认值
func parseIntDefault(s string, def int) int {
if v, err := strconv.Atoi(s); err == nil {
return v
}
return def
}
// daysAgo 返回 n 天前的时间
func daysAgo(n int) time.Time {
return time.Now().AddDate(0, 0, -n)
}
@@ -0,0 +1,75 @@
package api
import (
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login 登录 → JWT
func Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
var user model.User
if err := store.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
web.Fail(c, web.NewAuthError("用户名或密码错误"))
return
}
if user.Status != "active" {
web.Fail(c, web.NewAuthError("账号已禁用"))
return
}
if !auth.VerifyPassword(req.Password, user.PasswordHash) {
web.Fail(c, web.NewAuthError("用户名或密码错误"))
return
}
token, err := auth.CreateToken(user.Username, user.Role, Cfg.JWTSecret, Cfg.JWTExpireMin)
if err != nil {
web.Fail(c, web.NewBadRequest("令牌签发失败"))
return
}
web.OK(c, gin.H{
"token": token,
"expires_in": Cfg.JWTExpireMin * 60,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"full_name": user.FullName,
"role": user.Role,
"ai_points": user.AiPoints,
},
})
}
// Me 当前用户信息
func Me(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
web.OK(c, gin.H{
"id": u.ID,
"username": u.Username,
"full_name": u.FullName,
"role": u.Role,
"status": u.Status,
"ai_points": u.AiPoints,
"learning_points": u.LearningPoints,
})
}
@@ -0,0 +1,69 @@
package api
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// issueCertificate 正式考试通过后颁发证书(幂等:同一 exam_record 只发一张)。
func issueCertificate(u *model.User, rec model.ExamRecord) {
if u == nil || !rec.Passed {
return
}
var n int64
store.DB.Model(&model.Certificate{}).Where("exam_record_id = ?", rec.ID).Count(&n)
if n > 0 {
return
}
certNo := fmt.Sprintf("BST-%06d", rec.ID)
store.DB.Create(&model.Certificate{
UserID: u.ID, ExamRecordID: rec.ID, UserName: u.FullName,
ExamName: rec.ExamName, Score: rec.Score, TotalScore: rec.TotalScore, PassScore: rec.PassScore,
CertNo: certNo, IssuedAt: time.Now(),
})
}
// MyCertificates GET /api/exam/certificates —— 我的证书列表
func MyCertificates(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var items []model.Certificate
store.DB.Where("user_id = ?", u.ID).Order("issued_at DESC").Find(&items)
web.OK(c, items)
}
// CertificateDetail GET /api/exam/certificates/{id} —— 证书详情(本人或管理员可看,用于打印留存)
func CertificateDetail(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var cert model.Certificate
if err := store.DB.First(&cert, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("证书不存在"))
return
}
if u == nil || (u.Role != "admin" && cert.UserID != u.ID) {
web.Fail(c, web.NewForbiddenError("无权查看他人证书"))
return
}
web.OK(c, cert)
}
// AdminCertificates GET /api/system/certificates —— 全员证书(管理员)
func AdminCertificates(c *gin.Context) {
var items []model.Certificate
store.DB.Order("issued_at DESC").Find(&items)
web.OK(c, items)
}
@@ -0,0 +1,115 @@
package api
import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// GetCompanyTrain GET /api/company-train
// content 取 system_config(company_intro),medias 取已审批的公司绑定素材
func GetCompanyTrain(c *gin.Context) {
content := ""
var cfg model.SystemConfig
if err := store.DB.Where("config_key = ?", "company_intro").First(&cfg).Error; err == nil {
content = cfg.ConfigValue
}
var files []model.MediaFile
store.DB.Where("bind_type = ? AND status = ?", "company", "approved").
Order("id ASC").Find(&files)
type mediaItem struct {
ID uint `json:"id"`
Filename string `json:"filename"`
FileExt string `json:"file_ext"`
PreviewURL string `json:"preview_url"`
}
medias := make([]mediaItem, 0, len(files))
for _, f := range files {
medias = append(medias, mediaItem{
ID: f.ID,
Filename: f.Filename,
FileExt: f.FileExt,
PreviewURL: "/api/media/preview/" + strconv.FormatUint(uint64(f.ID), 10),
})
}
web.OK(c, gin.H{"content": content, "medias": medias})
}
// SuggestMaterial POST /api/company-train/suggest-material —— 员工提交素材建议(弹窗)
// 员工提交 → pending,待管理员审批;与管理员直传自动通过区分(审批前置)。
func SuggestMaterial(c *gin.Context) {
u := middleware.CurrentUser(c)
if u.Role != "employee" {
web.Fail(c, web.NewForbiddenError("仅普通员工可提交素材建议,请使用资料管理中的管理员上传入口"))
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
web.Fail(c, web.NewBadRequest("缺少文件字段 file"))
return
}
defer file.Close()
remark := strings.TrimSpace(c.PostForm("remark"))
ext := extOf(header.Filename)
if !allowedExt[ext] {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
return
}
if header.Size > sizeLimitFor(ext) {
web.Fail(c, web.NewBadRequest("文件超过大小限制"))
return
}
storedName := randomID() + "." + ext
dst := filepath.Join(pendingDir(), storedName)
if err := os.MkdirAll(pendingDir(), 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建存储目录失败"))
return
}
out, err := os.Create(dst)
if err != nil {
web.Fail(c, web.NewBadRequest("保存文件失败"))
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
web.Fail(c, web.NewBadRequest("写入文件失败"))
return
}
out.Close()
m := model.MediaFile{
Filename: header.Filename,
StoredName: storedName,
StoredPath: storedName,
FileExt: ext,
FileSize: header.Size,
Status: "pending",
Source: "employee",
SubmitterID: u.ID,
BindType: "none",
Remark: remark,
}
if err := store.DB.Create(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
return
}
web.OK(c, gin.H{"media_id": m.ID, "status": m.Status, "remark": remark})
}
@@ -0,0 +1,59 @@
package api
import (
"errors"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/connector"
"eaisalestrain/backend/internal/web"
)
// ListConnectors GET /api/connectors
func ListConnectors(c *gin.Context) {
web.OK(c, connector.ListDefinitions(Cfg))
}
// GetConnector GET /api/connectors/:key
func GetConnector(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("连接器 key 不能为空"))
return
}
definition, ok := connector.GetDefinition(Cfg, key)
if !ok {
web.Fail(c, web.NewNotFoundError("连接器不存在"))
return
}
web.OK(c, definition)
}
// QueryConnector POST /api/connectors/:key/query
func QueryConnector(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("连接器 key 不能为空"))
return
}
var req connector.QueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
result, err := connector.Query(c.Request.Context(), Cfg, key, req)
if err != nil {
switch {
case errors.Is(err, connector.ErrConnectorNotFound):
web.Fail(c, web.NewNotFoundError("连接器不存在"))
case errors.Is(err, connector.ErrConnectorQueryUnsupported):
web.Fail(c, web.NewBadRequest("该连接器当前不支持 query,请在动作面板中作为输出连接器使用"))
default:
web.Fail(c, web.NewBadRequest(err.Error()))
}
return
}
web.OK(c, result)
}
@@ -0,0 +1,169 @@
package api
import (
"strconv"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// courseView 课程详情视图(含绑定产品)
func courseView(c *gin.Context, co model.Course) {
out := gin.H{
"id": co.ID,
"code": co.Code,
"name": co.Name,
"category": co.Category,
"target_customers": co.TargetCustomers,
"forbidden_customers": co.ForbiddenCustomers,
"scripts": co.Scripts,
"sales_process": co.SalesProcess,
"objection_handling": co.ObjectionHandling,
"delivery_pitfalls": co.DeliveryPitfalls,
"report_rules": co.ReportRules,
"related_product_id": co.RelatedProductID,
"status": co.Status,
"created_at": co.CreatedAt,
"updated_at": co.UpdatedAt,
}
if co.RelatedProductID != nil {
var p model.Product
if store.DB.Where("id = ? AND status != ?", *co.RelatedProductID, "inactive").First(&p).Error == nil {
out["product"] = gin.H{"id": p.ID, "code": p.Code, "name": p.Name, "category": p.Category}
}
}
var medias []model.MediaFile
store.DB.Where("bind_type = ? AND bind_id = ? AND status = ?", "course", co.ID, "approved").
Order("id ASC").Find(&medias)
if len(medias) > 0 {
items := make([]gin.H, 0, len(medias))
for _, m := range medias {
items = append(items, gin.H{
"id": m.ID,
"filename": m.Filename,
"file_ext": m.FileExt,
"preview_url": "/api/media/preview/" + strconv.FormatUint(uint64(m.ID), 10),
})
}
out["medias"] = items
}
web.OK(c, out)
}
// ListCourses GET /api/courses?category=&status=
func ListCourses(c *gin.Context) {
q := store.DB.Model(&model.Course{})
if cat := c.Query("category"); cat != "" {
q = q.Where("category = ?", cat)
}
switch st := c.Query("status"); st {
case "": // 默认仅 active(员工浏览)
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Course
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询课程失败"))
return
}
web.OK(c, items)
}
// GetCourse GET /api/courses/{id}
func GetCourse(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var co model.Course
if err := store.DB.First(&co, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("课程不存在"))
return
}
courseView(c, co)
}
// CreateCourse POST /api/courses (admin)
func CreateCourse(c *gin.Context) {
var co model.Course
if err := c.ShouldBindJSON(&co); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if co.Code == "" || co.Name == "" || co.Category == "" {
web.Fail(c, web.NewBadRequest("编号、名称、分类为必填"))
return
}
var count int64
store.DB.Model(&model.Course{}).Where("code = ?", co.Code).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("课程编号已存在"))
return
}
if co.Status == "" {
co.Status = "active"
}
if err := store.DB.Create(&co).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建课程失败"))
return
}
web.OK(c, co)
}
// UpdateCourse PUT /api/courses/{id} (admin) —— 支持绑定/解绑产品
func UpdateCourse(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var co model.Course
if err := store.DB.First(&co, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("课程不存在"))
return
}
var req model.Course
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.Name == "" || req.Category == "" {
web.Fail(c, web.NewBadRequest("名称、分类为必填"))
return
}
if req.Status == "" {
req.Status = "active"
}
if req.Code != "" && req.Code != co.Code {
var count int64
store.DB.Model(&model.Course{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("课程编号已存在"))
return
}
co.Code = req.Code
}
co.Name = req.Name
co.Category = req.Category
co.TargetCustomers = req.TargetCustomers
co.ForbiddenCustomers = req.ForbiddenCustomers
co.Scripts = req.Scripts
co.SalesProcess = req.SalesProcess
co.ObjectionHandling = req.ObjectionHandling
co.DeliveryPitfalls = req.DeliveryPitfalls
co.ReportRules = req.ReportRules
co.RelatedProductID = req.RelatedProductID
co.Status = req.Status
if err := store.DB.Save(&co).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新课程失败"))
return
}
courseView(c, co)
}
@@ -0,0 +1,275 @@
package api
import (
"fmt"
"sort"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ListDepartments GET /api/departments?status= —— 部门字典列表(含成员数)
func ListDepartments(c *gin.Context) {
q := store.DB.Model(&model.Department{})
switch st := c.Query("status"); st {
case "": // 默认仅 active
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Department
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询部门失败"))
return
}
// 成员数按 user.department 字符串匹配(部门为字典、用户以字符串归属)
var users []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Find(&users)
nameCount := map[string]int{}
for _, u := range users {
if strings.TrimSpace(u.Department) != "" {
nameCount[u.Department]++
}
}
out := make([]gin.H, 0, len(items))
for _, d := range items {
out = append(out, gin.H{
"id": d.ID,
"name": d.Name,
"description": d.Description,
"status": d.Status,
"member_count": nameCount[d.Name],
"created_at": d.CreatedAt,
})
}
web.OK(c, out)
}
// CreateDepartment POST /api/departments (admin)
func CreateDepartment(c *gin.Context) {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
web.Fail(c, web.NewBadRequest("部门名称为必填"))
return
}
req.Name = strings.TrimSpace(req.Name)
var n int64
store.DB.Model(&model.Department{}).Where("name = ?", req.Name).Count(&n)
if n > 0 {
web.Fail(c, web.NewConflictError("部门名称已存在"))
return
}
d := model.Department{Name: req.Name, Description: req.Description, Status: "active"}
if err := store.DB.Create(&d).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建部门失败"))
return
}
web.OK(c, d)
}
// UpdateDepartment PUT /api/departments/{id} (admin) —— 改名时同步 user.department 字符串
func UpdateDepartment(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var d model.Department
if err := store.DB.First(&d, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("部门不存在"))
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
web.Fail(c, web.NewBadRequest("部门名称为必填"))
return
}
req.Name = strings.TrimSpace(req.Name)
oldName := d.Name
if req.Name != oldName {
var n int64
store.DB.Model(&model.Department{}).Where("name = ? AND id <> ?", req.Name, id).Count(&n)
if n > 0 {
web.Fail(c, web.NewConflictError("部门名称已存在"))
return
}
}
d.Name = req.Name
d.Description = req.Description
if req.Status == "active" || req.Status == "inactive" {
d.Status = req.Status
}
if err := store.DB.Save(&d).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新部门失败"))
return
}
// 改名后同步员工归属,保证按部门统计与展示一致
if req.Name != oldName {
store.DB.Model(&model.User{}).Where("department = ?", oldName).Update("department", req.Name)
}
web.OK(c, d)
}
// DeleteDepartment DELETE /api/departments/{id} (admin) —— 有员工归属时拒绝删除
func DeleteDepartment(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var d model.Department
if err := store.DB.First(&d, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("部门不存在"))
return
}
var n int64
store.DB.Model(&model.User{}).Where("department = ? AND status = ?", d.Name, "active").Count(&n)
if n > 0 {
web.Fail(c, web.NewConflictError(fmt.Sprintf("该部门下仍有 %d 名员工,请先调整其部门", n)))
return
}
if err := store.DB.Delete(&d).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除部门失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
// DepartmentStats GET /api/system/department-stats —— 按部门学情聚合
func DepartmentStats(c *gin.Context) {
var employees []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Find(&employees)
// 每用户聚合:积分 / 考试 / 学习进度
type userAgg struct {
Points int
FormalCount int
FormalPassed int
ScoreSum int
CompanyViewed bool
ProductCount int
CourseCount int
}
perUser := map[uint]*userAgg{}
get := func(id uint) *userAgg {
a := perUser[id]
if a == nil {
a = &userAgg{}
perUser[id] = a
}
return a
}
for _, e := range employees {
get(e.ID).Points = e.LearningPoints
}
var recs []model.ExamRecord
store.DB.Find(&recs)
for _, r := range recs {
a := get(r.UserID)
a.FormalCount++
if r.Passed {
a.FormalPassed++
}
a.ScoreSum += r.Score
}
var lps []model.LearningProgress
store.DB.Find(&lps)
for _, lp := range lps {
a := get(lp.UserID)
switch lp.ItemType {
case "company":
a.CompanyViewed = true
case "product":
a.ProductCount++
case "course":
a.CourseCount++
}
}
// 按部门字符串聚合
type deptAgg struct {
EmployeeCount int
PointsTotal int
FormalCount int
FormalPassed int
ScoreSum int
CompanyViewed int
ProductSum int
CourseSum int
}
perDept := map[string]*deptAgg{}
order := []string{}
for _, e := range employees {
name := strings.TrimSpace(e.Department)
if name == "" {
name = "未分组"
}
a := perDept[name]
if a == nil {
a = &deptAgg{}
perDept[name] = a
order = append(order, name)
}
a.EmployeeCount++
if u := perUser[e.ID]; u != nil {
a.PointsTotal += u.Points
a.FormalCount += u.FormalCount
a.FormalPassed += u.FormalPassed
a.ScoreSum += u.ScoreSum
if u.CompanyViewed {
a.CompanyViewed++
}
a.ProductSum += u.ProductCount
a.CourseSum += u.CourseCount
}
}
// 排序:未分组沉底,其余按名称
sort.Slice(order, func(i, j int) bool {
if order[i] == "未分组" {
return false
}
if order[j] == "未分组" {
return true
}
return order[i] < order[j]
})
out := make([]gin.H, 0, len(order))
for _, name := range order {
a := perDept[name]
passRate, avgScore, companyRate, productAvg, courseAvg := 0.0, 0.0, 0.0, 0.0, 0.0
if a.FormalCount > 0 {
passRate = round1(float64(a.FormalPassed) * 100 / float64(a.FormalCount))
avgScore = round1(float64(a.ScoreSum) / float64(a.FormalCount))
}
if a.EmployeeCount > 0 {
companyRate = round1(float64(a.CompanyViewed) * 100 / float64(a.EmployeeCount))
productAvg = round1(float64(a.ProductSum) / float64(a.EmployeeCount))
courseAvg = round1(float64(a.CourseSum) / float64(a.EmployeeCount))
}
out = append(out, gin.H{
"department": name,
"employee_count": a.EmployeeCount,
"points_total": a.PointsTotal,
"formal_count": a.FormalCount,
"formal_passed": a.FormalPassed,
"pass_rate": passRate,
"avg_score": avgScore,
"company_rate": companyRate,
"product_avg": productAvg,
"course_avg": courseAvg,
})
}
web.OK(c, gin.H{"items": out})
}
@@ -0,0 +1,88 @@
package api
import (
"encoding/json"
"fmt"
"strings"
"time"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/config"
)
// gradeEssay 调 LLM 按评分标准对简答题作答打分,返回 0~1 得分率与一句话评语。
// 复用 text_gen 路由(title_gen),走回退链,全程审计。失败时返回 error 由调用方决定降级。
func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string, error) {
route, err := config.GetRoute("title_gen")
if err != nil {
return 0, "", err
}
systemPrompt := "你是培训平台阅卷老师。请严格依据「评分标准」对学员的简答题答案打分。" +
"只输出一个 JSON 对象,格式:{\"score\": <0到1之间的得分率数字>, \"comment\": \"<一句中文评语>\"}。" +
"不要输出任何其他内容。"
userPrompt := "题目:" + stem + "\n评分标准:" + rubric + "\n学员答案:" + userAnswer
messages := []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
}
start := time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
return 0, "", err
}
ai.LogCall(ai.LogEntry{
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: usedRoute.Provider,
RouteID: usedRoute.RouteID, Model: usedRoute.Model, Success: true,
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
LatencyMs: int(time.Since(start).Milliseconds()),
})
score, comment, err := parseEssayScore(result.Content)
if err != nil {
return 0, "", err
}
return score, comment, nil
}
// parseEssayScore 从 LLM 返回文本中提取 JSON 的 score / comment,score 钳制到 [0,1]。
func parseEssayScore(content string) (float64, string, error) {
s := strings.TrimSpace(content)
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
i := strings.Index(s, "{")
j := strings.LastIndex(s, "}")
if i < 0 || j <= i {
return 0, "", fmt.Errorf("LLM 评分输出非 JSON:%s", truncateScore(content))
}
var out struct {
Score float64 `json:"score"`
Comment string `json:"comment"`
}
if err := json.Unmarshal([]byte(s[i:j+1]), &out); err != nil {
return 0, "", fmt.Errorf("LLM 评分 JSON 解析失败: %w", err)
}
if out.Score < 0 {
out.Score = 0
}
if out.Score > 1 {
out.Score = 1
}
return out.Score, out.Comment, nil
}
func truncateScore(s string) string {
r := []rune(s)
if len(r) <= 120 {
return s
}
return string(r[:120]) + "..."
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
package api
import (
"reflect"
"testing"
)
func TestIsCorrect(t *testing.T) {
cases := []struct {
name string
qtype string
correct []string
user any
want bool
}{
{"single-correct", "single", []string{"A"}, "A", true},
{"single-wrong", "single", []string{"A"}, "B", false},
{"single-empty-correct", "single", []string{}, "A", false},
{"multiple-order-insensitive", "multiple", []string{"A", "B"}, []string{"B", "A"}, true},
{"multiple-missing", "multiple", []string{"A", "B"}, []string{"A"}, false},
{"multiple-extra", "multiple", []string{"A", "B"}, []string{"A", "B", "C"}, false},
{"multiple-wrong-type", "multiple", []string{"A"}, "A", false},
{"judge-true", "judge", []string{"true"}, "true", true},
{"judge-case-insensitive", "judge", []string{"true"}, "True", true},
{"judge-false", "judge", []string{"true"}, "false", false},
{"judge-empty-correct", "judge", []string{}, "true", false},
}
for _, c := range cases {
if got := isCorrect(c.qtype, c.correct, c.user); got != c.want {
t.Errorf("%s: isCorrect(%q,%v,%v)=%v want %v", c.name, c.qtype, c.correct, c.user, got, c.want)
}
}
}
func TestSortedEqual(t *testing.T) {
if !sortedEqual([]string{"A", "B"}, []string{"B", "A"}) {
t.Error("sortedEqual should be order-insensitive")
}
if sortedEqual([]string{"A"}, []string{"A", "B"}) {
t.Error("sortedEqual should reject different lengths")
}
if sortedEqual([]string{"A"}, []string{"B"}) {
t.Error("sortedEqual should reject different contents")
}
}
func TestSplitDomains(t *testing.T) {
if got := splitDomains("company, product"); !reflect.DeepEqual(got, []string{"company", "product"}) {
t.Errorf("splitDomains = %v", got)
}
if got := splitDomains(""); len(got) != 0 {
t.Errorf("splitDomains empty = %v", got)
}
if got := splitDomains(" , "); len(got) != 0 {
t.Errorf("splitDomains blanks = %v", got)
}
}
func TestSplitIDs(t *testing.T) {
if got := splitIDs("1,2,3"); !reflect.DeepEqual(got, []uint{1, 2, 3}) {
t.Errorf("splitIDs = %v", got)
}
if got := splitIDs("1, ,2"); !reflect.DeepEqual(got, []uint{1, 2}) {
t.Errorf("splitIDs with blank = %v", got)
}
if got := splitIDs("0,abc"); len(got) != 0 {
t.Errorf("splitIDs invalid = %v", got)
}
}
func TestDedupe(t *testing.T) {
if got := dedupeStrings([]string{"a", "b", "a", "", "c"}); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
t.Errorf("dedupeStrings = %v", got)
}
if got := dedupeUints([]uint{1, 2, 1, 3}); !reflect.DeepEqual(got, []uint{1, 2, 3}) {
t.Errorf("dedupeUints = %v", got)
}
}
func TestNormalizeUserAnswer(t *testing.T) {
if got := normalizeUserAnswer("text"); got != "text" {
t.Errorf("string = %v", got)
}
if got := normalizeUserAnswer(true); got != "true" {
t.Errorf("bool = %v", got)
}
if got := normalizeUserAnswer([]string{"A", "B"}); !reflect.DeepEqual(got, []string{"A", "B"}) {
t.Errorf("[]string = %v", got)
}
if got := normalizeUserAnswer([]any{"A", 1}); !reflect.DeepEqual(got, []string{"A", "1"}) {
t.Errorf("[]any = %v", got)
}
if got := normalizeUserAnswer(nil); got != nil {
t.Errorf("nil = %v", got)
}
if got := normalizeUserAnswer(5.0); got != "5" {
t.Errorf("number = %v", got)
}
}
func TestParseEssayScore(t *testing.T) {
s, c, err := parseEssayScore(`{"score":0.8,"comment":"要点齐全"}`)
if err != nil || s != 0.8 || c != "要点齐全" {
t.Errorf("basic = %v,%v,%v", s, c, err)
}
s, _, err = parseEssayScore("```json\n{\"score\":0.5}\n```")
if err != nil || s != 0.5 {
t.Errorf("fenced = %v,%v", s, err)
}
s, _, err = parseEssayScore(`{"score":1.5}`)
if err != nil || s != 1 {
t.Errorf("clamp-high = %v,%v", s, err)
}
s, _, err = parseEssayScore(`{"score":-0.2}`)
if err != nil || s != 0 {
t.Errorf("clamp-low = %v,%v", s, err)
}
if _, _, err = parseEssayScore("评分 0.3 分"); err == nil {
t.Error("non-json should error")
}
}
func TestScoreBand(t *testing.T) {
cases := map[int]string{55: "0-59", 60: "60-69", 69: "60-69", 70: "70-79", 89: "80-89", 90: "90-100", 100: "90-100"}
for in, want := range cases {
if got := scoreBand(in); got != want {
t.Errorf("scoreBand(%d)=%s want %s", in, got, want)
}
}
}
func TestRound1(t *testing.T) {
if round1(66.66) != 66.7 {
t.Errorf("round1(66.66)=%v", round1(66.66))
}
if round1(50.0) != 50.0 {
t.Errorf("round1(50.0)=%v", round1(50.0))
}
}
@@ -0,0 +1,19 @@
package api
import (
"strconv"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/web"
)
// parseID 解析路径中的 uint id,失败返回 false 并已写入错误响应
func parseID(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
web.Fail(c, web.NewBadRequest("无效的 "+name))
return 0, false
}
return uint(id), true
}
@@ -0,0 +1,391 @@
package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ============ 扫描 ============
// KnowledgeScan POST /api/knowledge/scan —— 扫描 knowledge_source 目录建 pending 记录
func KnowledgeScan(c *gin.Context) {
entries, err := os.ReadDir(Cfg.KnowledgeSourceDir)
if err != nil {
web.Fail(c, web.NewBadRequest("知识源目录不存在:"+Cfg.KnowledgeSourceDir))
return
}
results := make([]gin.H, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
continue
}
if strings.HasPrefix(strings.ToUpper(e.Name()), "README") {
continue
}
full := filepath.Join(Cfg.KnowledgeSourceDir, e.Name())
data, err := os.ReadFile(full)
if err != nil {
continue
}
fm := parseFrontMatter(string(data))
if fm["category"] == "" || fm["version"] == "" {
continue // 非知识源文档,跳过
}
var existing model.KnowledgeSource
if err := store.DB.Where("file_path = ?", e.Name()).First(&existing).Error; err == nil {
results = append(results, gin.H{"file_path": e.Name(), "status": "skipped", "title": existing.Title})
continue
}
src := model.KnowledgeSource{
Title: parseTitle(string(data)),
FilePath: e.Name(),
Category: fm["category"],
Domain: orDefault(fm["domain"], "product"),
SourceVersion: fm["version"],
AuditStatus: "pending",
}
if err := store.DB.Create(&src).Error; err != nil {
results = append(results, gin.H{"file_path": e.Name(), "status": "error", "title": src.Title})
continue
}
results = append(results, gin.H{"file_path": e.Name(), "status": "created", "title": src.Title})
}
web.OK(c, gin.H{"results": results})
}
// KnowledgeAuditList GET /api/knowledge/audit-list?status=&page=&size=
func KnowledgeAuditList(c *gin.Context) {
q := store.DB.Model(&model.KnowledgeSource{})
if s := c.Query("status"); s != "" {
q = q.Where("audit_status = ?", s)
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
var total int64
q.Count(&total)
var items []model.KnowledgeSource
q.Order("id ASC").Offset((page - 1) * size).Limit(size).Find(&items)
web.OK(c, gin.H{"total": total, "items": items})
}
// KnowledgeAudit POST /api/knowledge/audit/{sourceId} —— 审批(approve→摄入 / reject→驳回)
func KnowledgeAudit(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "sourceId")
if !ok {
return
}
var src model.KnowledgeSource
if err := store.DB.First(&src, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("知识源不存在"))
return
}
var req struct {
Action string `json:"action"`
RejectReason string `json:"reject_reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
now := time.Now()
auditBy := u.ID
switch req.Action {
case "approve":
if src.Ingested {
web.OK(c, gin.H{"status": "approved", "source_id": src.ID, "products": 0, "chunks": 0, "questions": 0, "already_ingested": true})
return
}
counts, err := ingestSource(&src)
if err != nil {
web.Fail(c, web.NewBadRequest("摄入失败:"+err.Error()))
return
}
src.AuditStatus = "approved"
src.AuditBy = &auditBy
src.AuditAt = &now
src.RejectReason = ""
src.Ingested = true
if err := store.DB.Save(&src).Error; err != nil {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
web.OK(c, gin.H{
"status": "approved", "source_id": src.ID,
"products": counts[0], "chunks": counts[1], "questions": counts[2],
})
case "reject":
if strings.TrimSpace(req.RejectReason) == "" {
web.Fail(c, web.NewBadRequest("驳回理由必填"))
return
}
src.AuditStatus = "rejected"
src.RejectReason = req.RejectReason
src.AuditBy = &auditBy
src.AuditAt = &now
if err := store.DB.Save(&src).Error; err != nil {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
web.OK(c, gin.H{"status": "rejected", "source_id": src.ID})
default:
web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject"))
}
}
// KnowledgeStatus GET /api/knowledge/status/{sourceId}
func KnowledgeStatus(c *gin.Context) {
id, ok := parseID(c, "sourceId")
if !ok {
return
}
var src model.KnowledgeSource
if err := store.DB.First(&src, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("知识源不存在"))
return
}
web.OK(c, gin.H{
"audit_status": src.AuditStatus,
"ingested": src.Ingested,
"reject_reason": src.RejectReason,
})
}
// ============ md 解析 ============
func parseFrontMatter(content string) map[string]string {
m := map[string]string{}
parts := strings.SplitN(content, "---", 3)
if len(parts) < 3 {
return m
}
for _, line := range strings.Split(parts[1], "\n") {
line = strings.TrimSpace(line)
if line == "" || !strings.Contains(line, ":") {
continue
}
kv := strings.SplitN(line, ":", 2)
m[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
return m
}
func parseTitle(content string) string {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
}
return ""
}
type mdBlock struct {
title string
body string
}
func splitSections(content string) map[string]string {
sections := map[string]string{}
lines := strings.Split(content, "\n")
current := ""
var buf strings.Builder
for _, line := range lines {
if strings.HasPrefix(line, "## ") {
if current != "" {
sections[current] = buf.String()
}
current = strings.TrimSpace(strings.TrimPrefix(line, "## "))
buf.Reset()
} else {
buf.WriteString(line + "\n")
}
}
if current != "" {
sections[current] = buf.String()
}
return sections
}
func splitBlocks(body string) []mdBlock {
var blocks []mdBlock
current := ""
var buf strings.Builder
flush := func() {
if current != "" || strings.TrimSpace(buf.String()) != "" {
blocks = append(blocks, mdBlock{current, buf.String()})
}
}
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "### ") {
flush()
current = strings.TrimSpace(strings.TrimPrefix(line, "### "))
buf.Reset()
} else {
buf.WriteString(line + "\n")
}
}
flush()
return blocks
}
func parseKV(body string) map[string]string {
m := map[string]string{}
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "- ") {
continue
}
line = strings.TrimPrefix(line, "- ")
if i := strings.Index(line, ":"); i >= 0 {
m[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
}
}
return m
}
// indexKey 选项索引 → 键(0→A, 1→B ...)
func indexKey(i int) string {
if i < 0 || i > 25 {
return "?"
}
return string(rune('A' + i))
}
func convertOptions(raw []string) []Option {
opts := make([]Option, len(raw))
for i, text := range raw {
opts[i] = Option{Key: indexKey(i), Text: text}
}
return opts
}
func convertAnswer(raw []any) []string {
out := make([]string, 0, len(raw))
for _, a := range raw {
switch v := a.(type) {
case bool:
out = append(out, strconv.FormatBool(v))
case float64:
out = append(out, indexKey(int(v)))
case string:
out = append(out, v)
}
}
return out
}
// ============ 摄入 ============
// ingestSource 解析 md → product / knowledge_chunk / question,返回三表写入计数
func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
var counts [3]int
full := filepath.Join(Cfg.KnowledgeSourceDir, src.FilePath)
data, err := os.ReadFile(full)
if err != nil {
return counts, fmt.Errorf("读取文件失败")
}
content := string(data)
fm := parseFrontMatter(content)
cat := orDefault(src.Category, fm["category"])
sections := splitSections(content)
// 1. 产品
for _, b := range splitBlocks(sections["结构化产品数据"]) {
kv := parseKV(b.body)
if kv["code"] == "" || kv["name"] == "" {
continue
}
p := model.Product{
Code: kv["code"],
Name: kv["name"],
Category: orDefault(kv["category"], cat),
Tags: kv["tags"],
Description: kv["description"],
Pricing: kv["pricing"],
CommissionRecommend: kv["commission_recommend"],
CommissionNegotiate: kv["commission_negotiate"],
PublicCourseBonus: kv["public_course_bonus"],
VersionRisk: kv["version_risk"],
ReportRules: kv["report_rules"],
Status: "active",
}
var existing model.Product
if err := store.DB.Where("code = ?", p.Code).First(&existing).Error; err == nil {
p.ID = existing.ID
store.DB.Save(&p)
} else {
store.DB.Create(&p)
}
counts[0]++
}
// 2. AI 检索知识 → knowledge_chunk
for i, b := range splitBlocks(sections["AI 检索知识"]) {
text := strings.TrimSpace(b.body)
if text == "" {
continue
}
store.DB.Create(&model.KnowledgeChunk{
KnowledgeSourceID: &src.ID,
SourceType: "md",
SourceID: strconv.FormatUint(uint64(src.ID), 10),
ChunkIndex: i,
Content: text,
})
counts[1]++
}
// 3. 考试题目 → question
for _, b := range splitBlocks(sections["考试题目"]) {
kv := parseKV(b.body)
if kv["type"] == "" || kv["stem"] == "" {
continue
}
var optsRaw []string
_ = json.Unmarshal([]byte(kv["options"]), &optsRaw)
var ansRaw []any
_ = json.Unmarshal([]byte(kv["answer"]), &ansRaw)
optsJSON, _ := json.Marshal(convertOptions(optsRaw))
ansJSON, _ := json.Marshal(convertAnswer(ansRaw))
q := model.Question{
Domain: orDefault(kv["domain"], src.Domain),
Type: kv["type"],
Stem: kv["stem"],
Options: string(optsJSON),
Answer: string(ansJSON),
Explanation: kv["explanation"],
Status: "active",
}
store.DB.Create(&q)
counts[2]++
}
return counts, nil
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
@@ -0,0 +1,338 @@
package api
import (
"encoding/csv"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ============ 知识库导出 ============
//
// GET /api/knowledge/export?type=manual|questions|products&format=md|csv
// - manual:培训知识手册 md(产品 + 课程 + AI 检索知识 + 题库含答案)
// - questions:题库(md 或 csv)
// - products:产品清单(csv 或 md)
var productCatLabel = map[string]string{
"capital_consulting": "资本咨询类",
"qualification_counseling": "资质认定辅导类",
"ai_consulting": "AI 咨询类",
"ai_tools_platform": "AI 工具平台类",
}
var courseCatLabel = map[string]string{
"capital_script": "资本咨询话术",
"qualification_logic": "资质认定逻辑",
"ai_public_course": "AI 公开课",
"ai_platform_matching": "AI 平台匹配",
}
var domainLabel = map[string]string{
"company": "公司知识",
"product": "产品知识",
"sales": "销售知识",
}
var qtypeLabel = map[string]string{
"single": "单选题",
"multiple": "多选题",
"judge": "判断题",
"essay": "简答题",
}
func labelOf(m map[string]string, k string) string {
if v, ok := m[k]; ok {
return v
}
return k
}
// ExportKnowledge 知识库导出(管理员)
func ExportKnowledge(c *gin.Context) {
typ := c.DefaultQuery("type", "manual")
format := c.DefaultQuery("format", "md")
switch typ {
case "questions":
exportQuestions(c, format)
case "products":
exportProducts(c, format)
case "manual":
exportManual(c)
default:
web.Fail(c, web.NewBadRequest("type 必须为 manual / questions / products"))
}
}
// ---------- 数据读取 ----------
type qView struct {
Domain string
Type string
Stem string
Options []Option
Answer []string
Explanation string
}
func loadQuestions() []qView {
var items []model.Question
store.DB.Where("status = ?", "active").Order("id ASC").Find(&items)
out := make([]qView, 0, len(items))
for _, q := range items {
var opts []Option
_ = json.Unmarshal([]byte(q.Options), &opts)
var ans []string
_ = json.Unmarshal([]byte(q.Answer), &ans)
out = append(out, qView{
Domain: q.Domain, Type: q.Type, Stem: q.Stem,
Options: opts, Answer: ans, Explanation: q.Explanation,
})
}
return out
}
// answerText 答案 → 可读文本(判断题→正确/错误;选择→A、B)
func answerText(q qView) string {
if q.Type == "judge" {
if len(q.Answer) > 0 && (q.Answer[0] == "true" || q.Answer[0] == "正确") {
return "正确"
}
return "错误"
}
return strings.Join(q.Answer, "、")
}
// optionsText 选项 → "A. xxx;B. yyy"
func optionsText(q qView) string {
parts := make([]string, 0, len(q.Options))
for _, o := range q.Options {
parts = append(parts, o.Key+". "+o.Text)
}
return strings.Join(parts, ";")
}
// ---------- 导出:题库 ----------
func exportQuestions(c *gin.Context, format string) {
qs := loadQuestions()
if format == "csv" {
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="题库_`+time.Now().Format("20060102")+`.csv"`)
c.Writer.WriteString("\xEF\xBB\xBF")
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"编号", "题型", "知识域", "题干", "选项", "答案", "解析"})
for i, q := range qs {
_ = w.Write([]string{
strconv.Itoa(i + 1),
labelOf(qtypeLabel, q.Type),
labelOf(domainLabel, q.Domain),
q.Stem,
optionsText(q),
answerText(q),
q.Explanation,
})
}
w.Flush()
return
}
// md(默认)
var b strings.Builder
b.WriteString("# 题库(含答案)\n\n")
b.WriteString("> 导出时间:" + time.Now().Format("2006-01-02 15:04") + " · 共 " + strconv.Itoa(len(qs)) + " 题\n\n")
writeQuestionsMarkdown(&b, qs)
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="题库_`+time.Now().Format("20060102")+`.md"`)
c.String(200, b.String())
}
// ---------- 导出:产品清单 ----------
func exportProducts(c *gin.Context, format string) {
var items []model.Product
store.DB.Where("status = ?", "active").Order("id ASC").Find(&items)
if format == "md" {
var b strings.Builder
b.WriteString("# 产品清单\n\n")
b.WriteString("> 导出时间:" + time.Now().Format("2006-01-02 15:04") + " · 共 " + strconv.Itoa(len(items)) + " 个产品\n\n")
writeProductsMarkdown(&b, items)
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="产品清单_`+time.Now().Format("20060102")+`.md"`)
c.String(200, b.String())
return
}
// csv(默认)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="产品清单_`+time.Now().Format("20060102")+`.csv"`)
c.Writer.WriteString("\xEF\xBB\xBF")
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"编号", "产品名称", "分类", "标签", "说明", "收费", "佣金(推荐)", "佣金(谈单)", "公开课奖励", "版本风险", "报备规则"})
for i, p := range items {
_ = w.Write([]string{
strconv.Itoa(i + 1),
p.Name,
labelOf(productCatLabel, p.Category),
p.Tags,
p.Description,
p.Pricing,
p.CommissionRecommend,
p.CommissionNegotiate,
p.PublicCourseBonus,
p.VersionRisk,
p.ReportRules,
})
}
w.Flush()
}
// ---------- 导出:培训手册(md) ----------
func exportManual(c *gin.Context) {
var products []model.Product
store.DB.Where("status = ?", "active").Order("id ASC").Find(&products)
var courses []model.Course
store.DB.Where("status = ?", "active").Order("id ASC").Find(&courses)
var chunks []model.KnowledgeChunk
store.DB.Order("id ASC").Find(&chunks)
qs := loadQuestions()
var b strings.Builder
b.WriteString("# 博昇内部培训知识手册\n\n")
b.WriteString("> 导出时间:" + time.Now().Format("2006-01-02 15:04") + "\n")
b.WriteString("> 内容:产品知识 · 销售课程 · AI 检索知识 · 考试题库\n\n")
// 一、产品知识
b.WriteString("## 一、产品知识(" + strconv.Itoa(len(products)) + " 个)\n\n")
writeProductsMarkdown(&b, products)
// 二、销售课程
b.WriteString("## 二、销售课程(" + strconv.Itoa(len(courses)) + " 门)\n\n")
for i, co := range courses {
b.WriteString("### " + strconv.Itoa(i+1) + ". " + co.Name + "(" + labelOf(courseCatLabel, co.Category) + ")\n\n")
if co.TargetCustomers != "" {
b.WriteString("- 目标客户:" + strings.TrimSpace(co.TargetCustomers) + "\n")
}
if co.ForbiddenCustomers != "" {
b.WriteString("- 禁区客户:" + strings.TrimSpace(co.ForbiddenCustomers) + "\n")
}
if co.Scripts != "" {
b.WriteString("- 话术:" + strings.TrimSpace(co.Scripts) + "\n")
}
if co.SalesProcess != "" {
b.WriteString("- 销售流程:" + strings.TrimSpace(co.SalesProcess) + "\n")
}
if co.ObjectionHandling != "" {
b.WriteString("- 异议处理:" + strings.TrimSpace(co.ObjectionHandling) + "\n")
}
if co.DeliveryPitfalls != "" {
b.WriteString("- 避坑提醒:" + strings.TrimSpace(co.DeliveryPitfalls) + "\n")
}
if co.ReportRules != "" {
b.WriteString("- 报备规则:" + strings.TrimSpace(co.ReportRules) + "\n")
}
b.WriteString("\n")
}
// 三、AI 检索知识
b.WriteString("## 三、AI 检索知识(" + strconv.Itoa(len(chunks)) + " 个知识块)\n\n")
for i, ch := range chunks {
content := strings.TrimSpace(ch.Content)
if content == "" {
continue
}
b.WriteString("### 知识点 " + strconv.Itoa(i+1) + "\n\n")
b.WriteString(content + "\n\n")
}
// 四、考试题库
b.WriteString("## 四、考试题库(含答案,共 " + strconv.Itoa(len(qs)) + " 题)\n\n")
writeQuestionsMarkdown(&b, qs)
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="培训知识手册_`+time.Now().Format("20060102")+`.md"`)
c.String(200, b.String())
}
// writeProductsMarkdown 产品 → 分组 markdown
func writeProductsMarkdown(b *strings.Builder, items []model.Product) {
// 按分类分组,保持稳定顺序
groups := make(map[string][]model.Product)
var order []string
for _, p := range items {
if _, ok := groups[p.Category]; !ok {
order = append(order, p.Category)
}
groups[p.Category] = append(groups[p.Category], p)
}
idx := 0
for _, cat := range order {
ps := groups[cat]
for _, p := range ps {
idx++
b.WriteString("### " + strconv.Itoa(idx) + ". " + p.Name + "(" + labelOf(productCatLabel, p.Category) + ")\n\n")
if p.Tags != "" {
b.WriteString("- 标签:" + strings.TrimSpace(p.Tags) + "\n")
}
if p.Description != "" {
b.WriteString("- 说明:" + strings.TrimSpace(p.Description) + "\n")
}
if p.Pricing != "" {
b.WriteString("- 收费:" + strings.TrimSpace(p.Pricing) + "\n")
}
if p.CommissionRecommend != "" {
b.WriteString("- 佣金(仅推荐):" + strings.TrimSpace(p.CommissionRecommend) + "\n")
}
if p.CommissionNegotiate != "" {
b.WriteString("- 佣金(参与谈单):" + strings.TrimSpace(p.CommissionNegotiate) + "\n")
}
if p.PublicCourseBonus != "" {
b.WriteString("- 公开课奖励:" + strings.TrimSpace(p.PublicCourseBonus) + "\n")
}
if p.VersionRisk != "" {
b.WriteString("- 版本风险:" + strings.TrimSpace(p.VersionRisk) + "\n")
}
if p.ReportRules != "" {
b.WriteString("- 报备规则:" + strings.TrimSpace(p.ReportRules) + "\n")
}
b.WriteString("\n")
}
}
}
// writeQuestionsMarkdown 题目 → 按知识域分组 markdown
func writeQuestionsMarkdown(b *strings.Builder, qs []qView) {
groups := make(map[string][]qView)
var order []string
for _, q := range qs {
if _, ok := groups[q.Domain]; !ok {
order = append(order, q.Domain)
}
groups[q.Domain] = append(groups[q.Domain], q)
}
for _, d := range order {
qs := groups[d]
b.WriteString("### " + labelOf(domainLabel, d) + "(" + strconv.Itoa(len(qs)) + " 题)\n\n")
for i, q := range qs {
b.WriteString(strconv.Itoa(i+1) + ". [" + labelOf(qtypeLabel, q.Type) + "] " + q.Stem + "\n")
if len(q.Options) > 0 {
for _, o := range q.Options {
b.WriteString(" - " + o.Key + ". " + o.Text + "\n")
}
}
b.WriteString(" 答案:" + answerText(q) + "\n")
if q.Explanation != "" {
b.WriteString(" 解析:" + q.Explanation + "\n")
}
b.WriteString("\n")
}
}
}
@@ -0,0 +1,114 @@
package api
import (
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
var validItemTypes = map[string]bool{"company": true, "product": true, "course": true}
// RecordLearningProgress POST /api/learning/progress —— 员工浏览内容时上报进度
func RecordLearningProgress(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
ItemType string `json:"item_type"`
ItemID uint `json:"item_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || !validItemTypes[req.ItemType] {
web.Fail(c, web.NewBadRequest("item_type 必填且合法(company/product/course)"))
return
}
if req.ItemType == "company" {
req.ItemID = 0
}
lp := model.LearningProgress{UserID: u.ID, ItemType: req.ItemType, ItemID: req.ItemID}
// 幂等:已存在则仅刷新 updated_at;首次记录才加分(避免重复刷分)
var existing model.LearningProgress
if err := store.DB.Where("user_id = ? AND item_type = ? AND item_id = ?", u.ID, req.ItemType, req.ItemID).
First(&existing).Error; err != nil {
if createErr := store.DB.Create(&lp).Error; createErr != nil {
web.Fail(c, web.NewBadRequest("记录学习进度失败"))
return
}
awardFirstView(u.ID, req.ItemType, req.ItemID)
} else {
store.DB.Model(&existing).Update("updated_at", time.Now())
}
web.OK(c, gin.H{"recorded": true, "item_type": req.ItemType, "item_id": req.ItemID})
}
// MyLearningProgress GET /api/learning/me —— 我的学习进度
func MyLearningProgress(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var items []model.LearningProgress
store.DB.Where("user_id = ?", u.ID).Order("updated_at DESC").Find(&items)
web.OK(c, items)
}
// AdminLearningProgress GET /api/system/learning-progress —— 全员学习进度(管理员)
func AdminLearningProgress(c *gin.Context) {
var employees []model.User
store.DB.Where("role = ?", "employee").Order("id ASC").Find(&employees)
var items []model.LearningProgress
store.DB.Order("updated_at DESC").Find(&items)
type agg struct {
CompanyViewed bool `json:"company_viewed"`
ProductCount int `json:"product_count"`
CourseCount int `json:"course_count"`
TotalItems int `json:"total_items"`
LastViewedAt *time.Time `json:"last_viewed_at"`
}
perUser := map[uint]*agg{}
for _, lp := range items {
a := perUser[lp.UserID]
if a == nil {
a = &agg{}
perUser[lp.UserID] = a
}
switch lp.ItemType {
case "company":
a.CompanyViewed = true
case "product":
a.ProductCount++
case "course":
a.CourseCount++
}
a.TotalItems++
if a.LastViewedAt == nil || lp.UpdatedAt.After(*a.LastViewedAt) {
t := lp.UpdatedAt
a.LastViewedAt = &t
}
}
out := make([]gin.H, 0, len(employees))
for _, e := range employees {
a := perUser[e.ID]
if a == nil {
a = &agg{}
}
out = append(out, gin.H{
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
"department": e.Department, "position": e.Position,
"company_viewed": a.CompanyViewed, "product_count": a.ProductCount,
"course_count": a.CourseCount, "total_items": a.TotalItems,
"last_viewed_at": a.LastViewedAt,
})
}
web.OK(c, gin.H{"users": out})
}
+596
View File
@@ -0,0 +1,596 @@
package api
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
var allowedExt = map[string]bool{
"ppt": true, "pptx": true, "pdf": true, "doc": true, "docx": true,
"mp4": true, "png": true, "jpg": true, "jpeg": true,
}
var validBindType = map[string]bool{"company": true, "product": true, "course": true, "none": true}
const defaultChunkSize = 5 * 1024 * 1024 // 5MB
// randomID 生成随机十六进制标识(文件名/上传会话)
func randomID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b)
}
func extOf(filename string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
}
func sizeLimitFor(ext string) int64 {
if ext == "mp4" {
return Cfg.FileMaxVideo
}
return Cfg.FileMaxDoc
}
// 审批前置:未审批文件存 pending/、驳回文件存 rejected/ 子目录(均不对外公开),
// 审批通过后移动到 approved/ 子目录(静态公开,仅 approved 可访问)。
func approvedDir() string { return filepath.Join(Cfg.KBDataDir, "approved") }
func pendingDir() string { return filepath.Join(Cfg.KBDataDir, "pending") }
func rejectedDir() string { return filepath.Join(Cfg.KBDataDir, "rejected") }
// mediaPathFor 按状态返回素材物理路径
func mediaPathFor(m model.MediaFile) string {
switch m.Status {
case "approved":
return filepath.Join(approvedDir(), m.StoredName)
case "rejected":
return filepath.Join(rejectedDir(), m.StoredName)
default:
return filepath.Join(pendingDir(), m.StoredName)
}
}
func parseBind(c *gin.Context) (string, *uint) {
bt := c.PostForm("bind_type")
if bt == "" {
bt = "none"
}
if !validBindType[bt] {
bt = "none"
}
var bid *uint
if s := c.PostForm("bind_id"); s != "" {
if n, err := strconv.ParseUint(s, 10, 64); err == nil && n > 0 {
v := uint(n)
bid = &v
}
}
return bt, bid
}
func sourceOf(c *gin.Context) string {
u := middleware.CurrentUser(c)
if u != nil && u.Role == "admin" {
return "admin"
}
return "employee"
}
// ============ 直传 ============
// Upload POST /api/media/upload —— 文档/小视频直传
func Upload(c *gin.Context) {
u := middleware.CurrentUser(c)
file, header, err := c.Request.FormFile("file")
if err != nil {
web.Fail(c, web.NewBadRequest("缺少文件字段 file"))
return
}
defer file.Close()
ext := extOf(header.Filename)
if !allowedExt[ext] {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
return
}
if header.Size > sizeLimitFor(ext) {
web.Fail(c, web.NewBadRequest("文件超过大小限制"))
return
}
bindType, bindID := parseBind(c)
source := sourceOf(c)
status := "pending"
targetDir := pendingDir()
if source == "admin" {
status = "approved" // 管理员上传自动通过
targetDir = approvedDir()
}
storedName := randomID() + "." + ext
dst := filepath.Join(targetDir, storedName)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建存储目录失败"))
return
}
out, err := os.Create(dst)
if err != nil {
web.Fail(c, web.NewBadRequest("保存文件失败"))
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
web.Fail(c, web.NewBadRequest("写入文件失败"))
return
}
out.Close()
m := model.MediaFile{
Filename: header.Filename,
StoredName: storedName,
StoredPath: storedName,
FileExt: ext,
FileSize: header.Size,
Status: status,
Source: source,
SubmitterID: u.ID,
BindType: bindType,
BindID: bindID,
}
if err := store.DB.Create(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
return
}
if status == "approved" {
go runExtractPipeline(m.ID)
}
web.OK(c, gin.H{"media_id": m.ID, "status": m.Status, "filename": m.Filename})
}
// ============ 分片上传 ============
type uploadSession struct {
Filename string
FileSize int64
Ext string
BindType string
BindID *uint
ChunkSize int64
ChunkCount int
Chunks map[int]bool
}
var uploadSessions = struct {
sync.RWMutex
m map[string]*uploadSession
}{m: map[string]*uploadSession{}}
// UploadInit POST /api/media/upload-init —— 初始化分片上传
func UploadInit(c *gin.Context) {
var req struct {
Filename string `json:"filename"`
FileSize int64 `json:"file_size"`
BindType string `json:"bind_type"`
BindID *uint `json:"bind_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Filename == "" || req.FileSize <= 0 {
web.Fail(c, web.NewBadRequest("filename/file_size 必填"))
return
}
ext := extOf(req.Filename)
if !allowedExt[ext] {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
if req.FileSize > sizeLimitFor(ext) {
web.Fail(c, web.NewBadRequest("文件超过大小限制"))
return
}
if req.BindType == "" || !validBindType[req.BindType] {
req.BindType = "none"
}
id := randomID()
chunkCount := int((req.FileSize + defaultChunkSize - 1) / defaultChunkSize)
sess := &uploadSession{
Filename: req.Filename, FileSize: req.FileSize, Ext: ext,
BindType: req.BindType, BindID: req.BindID,
ChunkSize: defaultChunkSize, ChunkCount: chunkCount, Chunks: map[int]bool{},
}
uploadSessions.Lock()
uploadSessions.m[id] = sess
uploadSessions.Unlock()
web.OK(c, gin.H{"upload_id": id, "chunk_size": defaultChunkSize, "chunk_count": chunkCount})
}
// UploadChunk POST /api/media/upload-chunk —— 上传分片
func UploadChunk(c *gin.Context) {
id := c.PostForm("upload_id")
idx, err := strconv.Atoi(c.PostForm("chunk_index"))
if err != nil || id == "" {
web.Fail(c, web.NewBadRequest("upload_id/chunk_index 必填"))
return
}
uploadSessions.RLock()
sess := uploadSessions.m[id]
uploadSessions.RUnlock()
if sess == nil {
web.Fail(c, web.NewNotFoundError("上传会话不存在"))
return
}
if idx < 0 || idx >= sess.ChunkCount {
web.Fail(c, web.NewBadRequest("chunk_index 越界"))
return
}
file, _, err := c.Request.FormFile("file")
if err != nil {
web.Fail(c, web.NewBadRequest("缺少分片文件"))
return
}
defer file.Close()
tmpDir := filepath.Join(pendingDir(), "tmp", id)
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建临时目录失败"))
return
}
dst := filepath.Join(tmpDir, fmt.Sprintf("%06d", idx))
out, err := os.Create(dst)
if err != nil {
web.Fail(c, web.NewBadRequest("保存分片失败"))
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
web.Fail(c, web.NewBadRequest("写入分片失败"))
return
}
out.Close()
uploadSessions.Lock()
sess.Chunks[idx] = true
uploadSessions.Unlock()
web.OK(c, gin.H{"upload_id": id, "chunk_index": idx, "received": len(sess.Chunks)})
}
// UploadComplete POST /api/media/upload-complete —— 合并分片
func UploadComplete(c *gin.Context) {
u := middleware.CurrentUser(c)
var req struct {
UploadID string `json:"upload_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.UploadID == "" {
web.Fail(c, web.NewBadRequest("upload_id 必填"))
return
}
uploadSessions.RLock()
sess := uploadSessions.m[req.UploadID]
uploadSessions.RUnlock()
if sess == nil {
web.Fail(c, web.NewNotFoundError("上传会话不存在"))
return
}
if len(sess.Chunks) != sess.ChunkCount {
web.Fail(c, web.NewBadRequest(fmt.Sprintf("分片不完整:%d/%d", len(sess.Chunks), sess.ChunkCount)))
return
}
source := sourceOf(c)
status := "pending"
targetDir := pendingDir()
if source == "admin" {
status = "approved" // 管理员上传自动通过
targetDir = approvedDir()
}
storedName := randomID() + "." + sess.Ext
if err := os.MkdirAll(targetDir, 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建存储目录失败"))
return
}
final := filepath.Join(targetDir, storedName)
out, err := os.Create(final)
if err != nil {
web.Fail(c, web.NewBadRequest("创建最终文件失败"))
return
}
tmpDir := filepath.Join(pendingDir(), "tmp", req.UploadID)
for i := 0; i < sess.ChunkCount; i++ {
part := filepath.Join(tmpDir, fmt.Sprintf("%06d", i))
f, err := os.Open(part)
if err != nil {
out.Close()
web.Fail(c, web.NewBadRequest("读取分片失败"))
return
}
if _, err := io.Copy(out, f); err != nil {
f.Close()
out.Close()
web.Fail(c, web.NewBadRequest("合并分片失败"))
return
}
f.Close()
}
out.Close()
os.RemoveAll(tmpDir)
uploadSessions.Lock()
delete(uploadSessions.m, req.UploadID)
uploadSessions.Unlock()
m := model.MediaFile{
Filename: sess.Filename, StoredName: storedName, StoredPath: storedName,
FileExt: sess.Ext, FileSize: sess.FileSize, Status: status,
Source: source, SubmitterID: u.ID, BindType: sess.BindType, BindID: sess.BindID,
}
if err := store.DB.Create(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
return
}
if status == "approved" {
go runExtractPipeline(m.ID)
}
web.OK(c, gin.H{"media_id": m.ID, "status": m.Status, "filename": m.Filename})
}
// ============ 预览 / 状态 ============
// Preview GET /api/media/preview/{mediaId} —— 仅 approved 可预览
func Preview(c *gin.Context) {
id, ok := parseID(c, "mediaId")
if !ok {
return
}
var m model.MediaFile
if err := store.DB.First(&m, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("素材不存在"))
return
}
if m.Status != "approved" {
web.Fail(c, web.NewForbiddenError("素材未通过审批,不可预览"))
return
}
web.OK(c, gin.H{
"preview_url": "/media/" + m.StoredName,
"file_ext": m.FileExt,
"can_preview": true,
})
}
// MediaStatus GET /api/media/status/{mediaId}
func MediaStatus(c *gin.Context) {
id, ok := parseID(c, "mediaId")
if !ok {
return
}
var m model.MediaFile
if err := store.DB.First(&m, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("素材不存在"))
return
}
var chunkCount int64
store.DB.Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", m.ID).Count(&chunkCount)
web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount})
}
// ============ 审批 ============
// AuditList GET /api/media/audit-list?status=&page=&size=
func AuditList(c *gin.Context) {
q := store.DB.Model(&model.MediaFile{})
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
var total int64
q.Count(&total)
var items []model.MediaFile
q.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items)
web.OK(c, gin.H{"total": total, "items": items})
}
// AuditMedia POST /api/media/audit/{mediaId} —— 审批(通过→触发异步提取)
func AuditMedia(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "mediaId")
if !ok {
return
}
var m model.MediaFile
if err := store.DB.First(&m, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("素材不存在"))
return
}
if m.Status != "pending" {
web.Fail(c, web.NewConflictError("该素材已审批,不可重复操作"))
return
}
var req struct {
Action string `json:"action"`
RejectReason string `json:"reject_reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
now := time.Now()
auditBy := u.ID
switch req.Action {
case "approve":
// 审批前置:通过后才把文件从 pending/ 移动到 approved/(公开)
src := filepath.Join(pendingDir(), m.StoredName)
dst := filepath.Join(approvedDir(), m.StoredName)
if err := os.MkdirAll(approvedDir(), 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建公开目录失败"))
return
}
if err := os.Rename(src, dst); err != nil {
web.Fail(c, web.NewBadRequest("移动文件失败:"+err.Error()))
return
}
m.Status = "approved"
m.AuditBy = &auditBy
m.AuditAt = &now
m.RejectReason = ""
case "reject":
if strings.TrimSpace(req.RejectReason) == "" {
web.Fail(c, web.NewBadRequest("驳回理由必填"))
return
}
// 驳回:把文件从 pending/ 移到 rejected/,落实「审批前置」物理隔离
src := filepath.Join(pendingDir(), m.StoredName)
dst := filepath.Join(rejectedDir(), m.StoredName)
if err := os.MkdirAll(rejectedDir(), 0o755); err != nil {
web.Fail(c, web.NewBadRequest("创建驳回目录失败"))
return
}
if err := os.Rename(src, dst); err != nil {
web.Fail(c, web.NewBadRequest("移动文件失败:"+err.Error()))
return
}
m.Status = "rejected"
m.RejectReason = req.RejectReason
m.AuditBy = &auditBy
m.AuditAt = &now
default:
web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject"))
return
}
if err := store.DB.Save(&m).Error; err != nil {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
if m.Status == "approved" {
go runExtractPipeline(m.ID)
}
web.OK(c, gin.H{"id": m.ID, "status": m.Status})
}
// ============ 异步文档提取管线 ============
// runExtractPipeline 审批通过后:文档转 PDF → pdftotext → 切片入库
func runExtractPipeline(mediaID uint) {
var m model.MediaFile
if err := store.DB.First(&m, mediaID).Error; err != nil {
return
}
docExts := map[string]bool{"ppt": true, "pptx": true, "doc": true, "docx": true}
var text string
var err error
src := mediaPathFor(m)
switch {
case docExts[m.FileExt]:
text, err = libreofficeExtract(src)
case m.FileExt == "pdf":
text, err = pdftotextExtract(src)
default:
// 视频/图片:仅标记,不提取
store.DB.Model(&m).Update("extracted", true)
return
}
if err != nil {
log.Printf("[提取失败] media_id=%d: %v", mediaID, err)
return
}
chunks := splitIntoChunks(text, 1000)
for i, chunk := range chunks {
if strings.TrimSpace(chunk) == "" {
continue
}
store.DB.Create(&model.KnowledgeChunk{
MediaFileID: &m.ID,
SourceType: m.FileExt,
SourceID: strconv.FormatUint(uint64(m.ID), 10),
ChunkIndex: i,
Content: chunk,
})
}
store.DB.Model(&m).Update("extracted", true)
log.Printf("[提取完成] media_id=%d chunks=%d", mediaID, len(chunks))
}
func libreofficeExtract(src string) (string, error) {
tmp, err := os.MkdirTemp("", "lo-convert")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
// 独立 UserInstallation:systemd 加固(ProtectHome=true)下 HOME 不可写,
// 避免 LibreOffice profile 初始化失败。
profileURI := "file://" + filepath.Join(tmp, "profile")
cmd := exec.Command(Cfg.LibreOfficeBin, "-env:UserInstallation="+profileURI,
"--headless", "--convert-to", "pdf", "--outdir", tmp, src)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("libreoffice 转换失败: %v %s", err, out)
}
base := strings.TrimSuffix(filepath.Base(src), filepath.Ext(src)) + ".pdf"
return pdftotextExtract(filepath.Join(tmp, base))
}
func pdftotextExtract(pdf string) (string, error) {
cmd := exec.Command(Cfg.PdftotextBin, "-enc", "UTF-8", pdf, "-")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("pdftotext 提取失败: %v", err)
}
return string(out), nil
}
func splitIntoChunks(text string, maxChars int) []string {
paragraphs := strings.Split(text, "\n\n")
var chunks []string
current := ""
for _, p := range paragraphs {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if len(current)+len(p) > maxChars {
if current != "" {
chunks = append(chunks, current)
}
current = p
} else if current == "" {
current = p
} else {
current += "\n\n" + p
}
}
if current != "" {
chunks = append(chunks, current)
}
return chunks
}
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
var validNoteItemTypes = map[string]bool{"company": true, "product": true, "course": true}
// ListNotes GET /api/notes?item_type=&item_id= —— 我的学习笔记
func ListNotes(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
q := store.DB.Where("user_id = ?", u.ID)
if t := c.Query("item_type"); t != "" {
q = q.Where("item_type = ?", t)
}
if s := c.Query("item_id"); s != "" {
if n, err := strconv.ParseUint(s, 10, 64); err == nil {
q = q.Where("item_id = ?", n)
}
}
var items []model.StudyNote
if err := q.Order("updated_at DESC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询笔记失败"))
return
}
web.OK(c, items)
}
// CreateNote POST /api/notes —— 新增学习笔记
func CreateNote(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
ItemType string `json:"item_type"`
ItemID uint `json:"item_id"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if !validNoteItemTypes[req.ItemType] {
web.Fail(c, web.NewBadRequest("item_type 必须为 company/product/course"))
return
}
if strings.TrimSpace(req.Content) == "" {
web.Fail(c, web.NewBadRequest("笔记内容不能为空"))
return
}
n := model.StudyNote{UserID: u.ID, ItemType: req.ItemType, ItemID: req.ItemID, Content: strings.TrimSpace(req.Content)}
if err := store.DB.Create(&n).Error; err != nil {
web.Fail(c, web.NewBadRequest("保存笔记失败"))
return
}
web.OK(c, n)
}
// UpdateNote PUT /api/notes/{id} —— 编辑学习笔记
func UpdateNote(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var n model.StudyNote
if err := store.DB.First(&n, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("笔记不存在"))
return
}
if n.UserID != u.ID {
web.Fail(c, web.NewForbiddenError("无权操作他人笔记"))
return
}
var req struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if strings.TrimSpace(req.Content) == "" {
web.Fail(c, web.NewBadRequest("笔记内容不能为空"))
return
}
n.Content = strings.TrimSpace(req.Content)
if err := store.DB.Save(&n).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新笔记失败"))
return
}
web.OK(c, n)
}
// DeleteNote DELETE /api/notes/{id} —— 删除学习笔记
func DeleteNote(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var n model.StudyNote
if err := store.DB.First(&n, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("笔记不存在"))
return
}
if n.UserID != u.ID {
web.Fail(c, web.NewForbiddenError("无权操作他人笔记"))
return
}
if err := store.DB.Delete(&n).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除笔记失败"))
return
}
web.OK(c, gin.H{"id": id})
}
@@ -0,0 +1,88 @@
package api
import (
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// notifyUser 给单个用户发站内通知(userID=0 忽略)。
func notifyUser(userID uint, ntype, title, content, link string) {
if userID == 0 {
return
}
store.DB.Create(&model.Notification{
UserID: userID, Type: ntype, Title: title, Content: content, Link: link,
})
}
// notifyAllEmployees 给所有启用员工发通知(如新正式考试发布)。
func notifyAllEmployees(ntype, title, content, link string) {
var users []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Select("id").Find(&users)
for _, u := range users {
notifyUser(u.ID, ntype, title, content, link)
}
}
// MyNotifications GET /api/notifications?unread_only=true —— 我的通知列表
func MyNotifications(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
q := store.DB.Model(&model.Notification{}).Where("user_id = ?", u.ID)
if c.Query("unread_only") == "true" {
q = q.Where("read = ?", false)
}
var items []model.Notification
q.Order("created_at DESC, id DESC").Limit(100).Find(&items)
web.OK(c, items)
}
// UnreadNotificationCount GET /api/notifications/unread-count —— 未读数(铃铛角标)
func UnreadNotificationCount(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var n int64
store.DB.Model(&model.Notification{}).Where("user_id = ? AND read = ?", u.ID, false).Count(&n)
web.OK(c, gin.H{"unread": n})
}
// MarkNotificationRead PUT /api/notifications/{id}/read —— 标记单条已读
func MarkNotificationRead(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var n model.Notification
if err := store.DB.First(&n, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("通知不存在"))
return
}
if n.UserID != u.ID {
web.Fail(c, web.NewForbiddenError("无权操作他人通知"))
return
}
store.DB.Model(&n).Update("read", true)
web.OK(c, gin.H{"id": n.ID, "read": true})
}
// MarkAllNotificationsRead PUT /api/notifications/read-all —— 全部标记已读
func MarkAllNotificationsRead(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
store.DB.Model(&model.Notification{}).Where("user_id = ? AND read = ?", u.ID, false).Update("read", true)
web.OK(c, gin.H{"read": true})
}
@@ -0,0 +1,90 @@
package api
import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// —— 积分规则(游戏化成长值,集中一处便于调整,见 docs/changelog.md 待确认项)——
const (
ptFirstCompany = 10 // 首次浏览公司介绍
ptFirstProduct = 2 // 首次浏览单个产品
ptFirstCourse = 5 // 首次浏览单门课程
ptSelfTestSubmit = 5 // 自测提交一次
ptFormalPass = 50 // 正式考试通过
ptMistakeResolved = 3 // 错题标记已掌握
)
// awardPoints 记录积分流水并累加到 user.learning_points(points<=0 直接忽略,避免负分刷分)。
func awardPoints(userID uint, eventType string, points int, refType string, refID uint) {
if userID == 0 || points <= 0 {
return
}
store.DB.Create(&model.PointEvent{
UserID: userID,
EventType: eventType,
Points: points,
RefType: refType,
RefID: refID,
})
store.DB.Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("learning_points", gorm.Expr("learning_points + ?", points))
}
// awardFirstView 首次浏览公司/产品/课程加分(由 RecordLearningProgress 幂等触发)。
func awardFirstView(userID uint, itemType string, itemID uint) {
switch itemType {
case "company":
awardPoints(userID, "first_company", ptFirstCompany, itemType, itemID)
case "product":
awardPoints(userID, "first_product", ptFirstProduct, itemType, itemID)
case "course":
awardPoints(userID, "first_course", ptFirstCourse, itemType, itemID)
}
}
// MyPoints GET /api/points/me —— 我的积分 + 最近积分流水
func MyPoints(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var total int
store.DB.Raw(`SELECT COALESCE(SUM(points), 0) FROM point_event WHERE user_id = ?`, u.ID).Scan(&total)
var events []model.PointEvent
store.DB.Where("user_id = ?", u.ID).Order("created_at DESC, id DESC").Limit(50).Find(&events)
web.OK(c, gin.H{"total": total, "events": events})
}
// Leaderboard GET /api/points/leaderboard —— 学习积分排行榜(全员,含我的名次)
func Leaderboard(c *gin.Context) {
u := middleware.CurrentUser(c)
var users []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").
Order("learning_points DESC, id ASC").Find(&users)
out := make([]gin.H, 0, len(users))
myRank := 0
for i, usr := range users {
out = append(out, gin.H{
"rank": i + 1,
"user_id": usr.ID,
"full_name": usr.FullName,
"department": usr.Department,
"learning_points": usr.LearningPoints,
"is_me": u != nil && usr.ID == u.ID,
})
if u != nil && usr.ID == u.ID {
myRank = i + 1
}
}
web.OK(c, gin.H{"items": out, "my_rank": myRank, "total": len(out), "updated_at": time.Now()})
}
@@ -0,0 +1,397 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ============ 岗位 CRUD(管理员) ============
// ListPositions GET /api/positions?status= —— 岗位列表
func ListPositions(c *gin.Context) {
q := store.DB.Model(&model.Position{})
switch st := c.Query("status"); st {
case "": // 默认仅 active
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Position
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询岗位失败"))
return
}
web.OK(c, items)
}
// CreatePosition POST /api/positions (admin)
func CreatePosition(c *gin.Context) {
var p model.Position
if err := c.ShouldBindJSON(&p); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if p.Code == "" || p.Name == "" {
web.Fail(c, web.NewBadRequest("编号、名称为必填"))
return
}
var count int64
store.DB.Model(&model.Position{}).Where("code = ?", p.Code).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("岗位编号已存在"))
return
}
if p.Status == "" {
p.Status = "active"
}
if err := store.DB.Create(&p).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建岗位失败"))
return
}
web.OK(c, p)
}
// UpdatePosition PUT /api/positions/{id} (admin)
func UpdatePosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Position
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req model.Position
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.Name == "" {
web.Fail(c, web.NewBadRequest("名称为必填"))
return
}
if req.Status == "" {
req.Status = "active"
}
if req.Code != "" && req.Code != p.Code {
var count int64
store.DB.Model(&model.Position{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("岗位编号已存在"))
return
}
p.Code = req.Code
}
p.Name = req.Name
p.Description = req.Description
p.Status = req.Status
if err := store.DB.Save(&p).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新岗位失败"))
return
}
web.OK(c, p)
}
// DeletePosition DELETE /api/positions/{id} (admin) —— 软删除(status=inactive)
func DeletePosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Position
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil {
web.Fail(c, web.NewBadRequest("停用岗位失败"))
return
}
web.OK(c, gin.H{"id": id, "status": "inactive"})
}
// ============ 岗位知识映射(管理员) ============
// positionKnowledgeReq 映射条目入参(与 model.PositionKnowledge 同构)
type positionKnowledgeReq struct {
Domain string `json:"domain"`
CourseID *uint `json:"course_id"`
ProductID *uint `json:"product_id"`
RequiredLevel string `json:"required_level"`
Weight float64 `json:"weight"`
IsMandatory bool `json:"is_mandatory"`
}
// ListPositionKnowledge GET /api/positions/{id}/knowledge —— 某岗位知识映射列表
func ListPositionKnowledge(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var items []model.PositionKnowledge
if err := store.DB.Where("position_id = ?", id).Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询岗位知识映射失败"))
return
}
web.OK(c, items)
}
// SavePositionKnowledge PUT /api/positions/{id}/knowledge —— 保存映射(整表覆盖)
func SavePositionKnowledge(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var pos model.Position
if err := store.DB.First(&pos, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req struct {
Items []positionKnowledgeReq `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
// 校验并规整映射条目
rows := make([]model.PositionKnowledge, 0, len(req.Items))
for _, it := range req.Items {
if !validDomains[it.Domain] {
web.Fail(c, web.NewBadRequest("知识域 domain 非法(仅 company/product/sales)"))
return
}
if it.RequiredLevel == "" {
it.RequiredLevel = model.LevelL1
}
if !model.ValidLevels[it.RequiredLevel] {
web.Fail(c, web.NewBadRequest("级别 required_level 非法(仅 L1/L2/L3/L4)"))
return
}
if it.Weight <= 0 {
it.Weight = 1
}
rows = append(rows, model.PositionKnowledge{
PositionID: id,
Domain: it.Domain,
CourseID: it.CourseID,
ProductID: it.ProductID,
RequiredLevel: it.RequiredLevel,
Weight: it.Weight,
IsMandatory: it.IsMandatory,
})
}
// 整表覆盖:先删旧,再批量插入
if err := store.DB.Where("position_id = ?", id).Delete(&model.PositionKnowledge{}).Error; err != nil {
web.Fail(c, web.NewBadRequest("清除旧映射失败"))
return
}
if len(rows) > 0 {
if err := store.DB.Create(&rows).Error; err != nil {
web.Fail(c, web.NewBadRequest("保存岗位知识映射失败"))
return
}
}
var out []model.PositionKnowledge
store.DB.Where("position_id = ?", id).Order("id ASC").Find(&out)
web.OK(c, out)
}
// ============ 用户设岗(管理员) ============
// SetUserPosition PUT /api/users/{id}/position —— {position_id: 5 | null}
func SetUserPosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var u model.User
if err := store.DB.First(&u, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("用户不存在"))
return
}
var req struct {
PositionID *uint `json:"position_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
posName := ""
if req.PositionID != nil {
var pos model.Position
if err := store.DB.First(&pos, *req.PositionID).Error; err != nil || pos.Status != "active" {
web.Fail(c, web.NewBadRequest("岗位不存在或已停用"))
return
}
posName = pos.Name
}
u.PositionID = req.PositionID
if err := store.DB.Save(&u).Error; err != nil {
web.Fail(c, web.NewBadRequest("设置用户岗位失败"))
return
}
if req.PositionID != nil {
notifyUser(u.ID, "position_set", "岗位已设置",
fmt.Sprintf("你的岗位已设置为「%s」,可在「我的岗位清单」查看应学内容", posName),
"/exam/my-position")
}
web.OK(c, gin.H{"id": u.ID, "position_id": u.PositionID})
}
// ============ 学员:我的岗位应学清单 ============
// MyPosition GET /api/my/position —— 我的岗位 + 应学范围(课程/产品/域)
func MyPosition(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil || u.PositionID == nil {
web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0})
return
}
var pos model.Position
if err := store.DB.First(&pos, *u.PositionID).Error; err != nil || pos.Status != "active" {
web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0})
return
}
var pks []model.PositionKnowledge
store.DB.Where("position_id = ?", pos.ID).Order("id ASC").Find(&pks)
// 批量解析课程/产品名称
courseIDs := make([]uint, 0, len(pks))
productIDs := make([]uint, 0, len(pks))
for _, pk := range pks {
if pk.CourseID != nil {
courseIDs = append(courseIDs, *pk.CourseID)
}
if pk.ProductID != nil {
productIDs = append(productIDs, *pk.ProductID)
}
}
courseName := map[uint]string{}
if len(courseIDs) > 0 {
var courses []model.Course
store.DB.Where("id IN ?", courseIDs).Find(&courses)
for _, c := range courses {
courseName[c.ID] = c.Name
}
}
productName := map[uint]string{}
if len(productIDs) > 0 {
var products []model.Product
store.DB.Where("id IN ?", productIDs).Find(&products)
for _, p := range products {
productName[p.ID] = p.Name
}
}
list := make([]gin.H, 0, len(pks))
for _, pk := range pks {
list = append(list, gin.H{
"id": pk.ID,
"domain": pk.Domain,
"course_id": pk.CourseID,
"product_id": pk.ProductID,
"course_name": nameOrDash(courseName, pk.CourseID),
"product_name": nameOrDash(productName, pk.ProductID),
"required_level": pk.RequiredLevel,
"weight": pk.Weight,
"is_mandatory": pk.IsMandatory,
})
}
web.OK(c, gin.H{
"position": pos,
"knowledge": list,
"count": len(list),
})
}
func nameOrDash(m map[uint]string, id *uint) string {
if id == nil {
return ""
}
if n, ok := m[*id]; ok {
return n
}
return ""
}
// ============ 岗位考试蓝图(管理员) ============
type blueprintReq struct {
Domain string `json:"domain"`
Type string `json:"type"`
Count int `json:"count"`
}
// ListPositionBlueprint GET /api/positions/{id}/blueprint —— 某岗位考试蓝图列表
func ListPositionBlueprint(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var items []model.PositionExamBlueprint
if err := store.DB.Where("position_id = ?", id).Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询岗位考试蓝图失败"))
return
}
web.OK(c, items)
}
// SavePositionBlueprint PUT /api/positions/{id}/blueprint —— 保存蓝图(整表覆盖)
func SavePositionBlueprint(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var pos model.Position
if err := store.DB.First(&pos, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req struct {
Items []blueprintReq `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
rows := make([]model.PositionExamBlueprint, 0, len(req.Items))
for _, it := range req.Items {
if !validQTypes[it.Type] {
web.Fail(c, web.NewBadRequest("题型 type 非法(仅 single/multiple/judge/essay)"))
return
}
if it.Domain != "" && !validDomains[it.Domain] {
web.Fail(c, web.NewBadRequest("知识域 domain 非法(仅 company/product/sales,或留空不限)"))
return
}
if it.Count <= 0 {
web.Fail(c, web.NewBadRequest("蓝图各条 count 必须大于 0"))
return
}
rows = append(rows, model.PositionExamBlueprint{
PositionID: id, Domain: it.Domain, Type: it.Type, Count: it.Count,
})
}
if err := store.DB.Where("position_id = ?", id).Delete(&model.PositionExamBlueprint{}).Error; err != nil {
web.Fail(c, web.NewBadRequest("清除旧蓝图失败"))
return
}
if len(rows) > 0 {
if err := store.DB.Create(&rows).Error; err != nil {
web.Fail(c, web.NewBadRequest("保存岗位考试蓝图失败"))
return
}
}
var out []model.PositionExamBlueprint
store.DB.Where("position_id = ?", id).Order("id ASC").Find(&out)
web.OK(c, out)
}
@@ -0,0 +1,207 @@
package api
import (
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ListProducts GET /api/products?category=&status=
func ListProducts(c *gin.Context) {
q := store.DB.Model(&model.Product{})
if cat := c.Query("category"); cat != "" {
q = q.Where("category = ?", cat)
}
switch st := c.Query("status"); st {
case "": // 默认仅 active(员工浏览)
q = q.Where("status = ?", "active")
case "all": // 管理员维护全量
default:
q = q.Where("status = ?", st)
}
var items []model.Product
if err := q.Order("id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询产品失败"))
return
}
web.OK(c, items)
}
// GetProduct GET /api/products/{id}
func GetProduct(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Product
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("产品不存在"))
return
}
web.OK(c, p)
}
// CreateProduct POST /api/products (admin)
func CreateProduct(c *gin.Context) {
var p model.Product
if err := c.ShouldBindJSON(&p); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if p.Code == "" || p.Name == "" || p.Category == "" {
web.Fail(c, web.NewBadRequest("编号、名称、分类为必填"))
return
}
var count int64
store.DB.Model(&model.Product{}).Where("code = ?", p.Code).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("产品编号已存在"))
return
}
if p.Status == "" {
p.Status = "active"
}
if err := store.DB.Create(&p).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建产品失败"))
return
}
web.OK(c, p)
}
// UpdateProduct PUT /api/products/{id} (admin)
func UpdateProduct(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Product
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("产品不存在"))
return
}
var req model.Product
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.Name == "" || req.Category == "" {
web.Fail(c, web.NewBadRequest("名称、分类为必填"))
return
}
if req.Status == "" {
req.Status = "active"
}
if req.Code != "" && req.Code != p.Code {
var count int64
store.DB.Model(&model.Product{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("产品编号已存在"))
return
}
p.Code = req.Code
}
p.Name = req.Name
p.Category = req.Category
p.Tags = req.Tags
p.Description = req.Description
p.Pricing = req.Pricing
p.CommissionRecommend = req.CommissionRecommend
p.CommissionNegotiate = req.CommissionNegotiate
p.PublicCourseBonus = req.PublicCourseBonus
p.VersionRisk = req.VersionRisk
p.ReportRules = req.ReportRules
p.Status = req.Status
if err := store.DB.Save(&p).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新产品失败"))
return
}
web.OK(c, p)
}
// DeleteProduct DELETE /api/products/{id} (admin) —— 软删除(status=inactive)
func DeleteProduct(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var p model.Product
if err := store.DB.First(&p, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("产品不存在"))
return
}
if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil {
web.Fail(c, web.NewBadRequest("停用产品失败"))
return
}
web.OK(c, gin.H{"id": id, "status": "inactive"})
}
// ImportProducts POST /api/products/import (admin) —— 按 code 幂等 upsert
func ImportProducts(c *gin.Context) {
var items []model.Product
if err := c.ShouldBindJSON(&items); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if len(items) == 0 {
web.Fail(c, web.NewBadRequest("导入数据为空"))
return
}
created, updated := 0, 0
for _, it := range items {
if it.Code == "" || it.Name == "" || it.Category == "" {
continue
}
if it.Status == "" {
it.Status = "active"
}
var existing model.Product
err := store.DB.Where("code = ?", it.Code).First(&existing).Error
if err != nil {
if store.DB.Create(&it).Error == nil {
created++
}
continue
}
// 合并:仅覆盖导入数据中非零的字段,避免零值抹除已有数据
if it.Name != "" {
existing.Name = it.Name
}
if it.Category != "" {
existing.Category = it.Category
}
if it.Tags != "" {
existing.Tags = it.Tags
}
if it.Description != "" {
existing.Description = it.Description
}
if it.Pricing != "" {
existing.Pricing = it.Pricing
}
if it.CommissionRecommend != "" {
existing.CommissionRecommend = it.CommissionRecommend
}
if it.CommissionNegotiate != "" {
existing.CommissionNegotiate = it.CommissionNegotiate
}
if it.PublicCourseBonus != "" {
existing.PublicCourseBonus = it.PublicCourseBonus
}
if it.VersionRisk != "" {
existing.VersionRisk = it.VersionRisk
}
if it.ReportRules != "" {
existing.ReportRules = it.ReportRules
}
existing.Status = it.Status
if store.DB.Save(&existing).Error == nil {
updated++
}
}
web.OK(c, gin.H{"created": created, "updated": updated, "total": len(items)})
}
@@ -0,0 +1,162 @@
package api
import (
"encoding/json"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// domainLabels 能力雷达维度(正式考试按域聚合的掌握度)。
var domainLabels = map[string]string{
"company": "公司认知",
"product": "产品知识",
"sales": "销售能力",
}
// domainOrder 雷达图固定排序。
var domainOrder = []string{"company", "product", "sales"}
// detailWrap 正式考试答题明细(与 ExamSubmit 持久化的 detail_json 结构对齐)。
type detailWrap struct {
Questions []struct {
QuestionID uint `json:"question_id"`
IsCorrect bool `json:"is_correct"`
} `json:"questions"`
}
// MyProfile GET /api/my/profile —— 学员学习档案:能力雷达 + 学习统计 + 成绩趋势 + 积分。
func MyProfile(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
// 学习进度(公司/产品/课程浏览)
var lps []model.LearningProgress
store.DB.Where("user_id = ?", u.ID).Find(&lps)
companyViewed := false
productViewed, courseViewed := 0, 0
for _, lp := range lps {
switch lp.ItemType {
case "company":
companyViewed = true
case "product":
productViewed++
case "course":
courseViewed++
}
}
// 错题统计
var mistakeTotal, mistakeResolved int64
store.DB.Model(&model.MistakeRecord{}).Where("user_id = ?", u.ID).Count(&mistakeTotal)
store.DB.Model(&model.MistakeRecord{}).Where("user_id = ? AND resolved = ?", u.ID, true).Count(&mistakeResolved)
// 自测次数(自测不落 exam_record,改由积分流水统计)
var selfTestCount int64
store.DB.Model(&model.PointEvent{}).Where("user_id = ? AND event_type = ?", u.ID, "self_test").Count(&selfTestCount)
// 正式考试记录 + 按域能力聚合
var recs []model.ExamRecord
store.DB.Where("user_id = ?", u.ID).Order("submitted_at ASC").Find(&recs)
// 题目 → 域映射(用于从答题明细反推各域掌握度)
var questions []model.Question
store.DB.Select("id", "domain").Find(&questions)
qDomain := map[uint]string{}
for _, q := range questions {
qDomain[q.ID] = q.Domain
}
type domainAgg struct {
Correct int `json:"correct"`
Total int `json:"total"`
}
perDomain := map[string]*domainAgg{}
formalPassed := 0
trend := make([]gin.H, 0, len(recs))
for _, r := range recs {
if r.Passed {
formalPassed++
}
trend = append(trend, gin.H{
"exam_name": r.ExamName,
"score": r.Score,
"total_score": r.TotalScore,
"passed": r.Passed,
"submitted_at": r.SubmittedAt,
})
var dw detailWrap
if err := json.Unmarshal([]byte(r.DetailJSON), &dw); err != nil {
continue
}
for _, qd := range dw.Questions {
domain := qDomain[qd.QuestionID]
if domain == "" {
continue
}
a := perDomain[domain]
if a == nil {
a = &domainAgg{}
perDomain[domain] = a
}
a.Total++
if qd.IsCorrect {
a.Correct++
}
}
}
domains := make([]gin.H, 0, len(domainOrder))
for _, d := range domainOrder {
a := perDomain[d]
if a == nil {
a = &domainAgg{}
}
mastery := 0.0
if a.Total > 0 {
mastery = round1(float64(a.Correct) * 100 / float64(a.Total))
}
domains = append(domains, gin.H{
"domain": d,
"label": domainLabels[d],
"correct": a.Correct,
"total": a.Total,
"mastery": mastery,
})
}
// 薄弱点:有作答记录中掌握度最低的域
weakDomain := ""
weakMastery := 100.0
for _, d := range domains {
if d["total"].(int) > 0 && d["mastery"].(float64) < weakMastery {
weakMastery = d["mastery"].(float64)
weakDomain = d["domain"].(string)
}
}
web.OK(c, gin.H{
"learning_points": u.LearningPoints,
"stats": gin.H{
"company_viewed": companyViewed,
"product_viewed": productViewed,
"course_viewed": courseViewed,
"self_test_count": selfTestCount,
"formal_count": len(recs),
"formal_passed": formalPassed,
"mistake_count": mistakeTotal,
"mistake_resolved": mistakeResolved,
},
"domains": domains,
"weak_domain": weakDomain,
"recent_scores": trend,
})
}
@@ -0,0 +1,179 @@
package api
import (
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/middleware"
)
// Cfg 全局配置,RegisterRoutes 时注入
var Cfg *config.Config
// RegisterRoutes 注册所有路由
func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
Cfg = cfg
// 素材静态预览(只读):仅公开 approved/ 子目录(已审批通过文件),
// pending/ 未审批文件不对外暴露,落实「审批前置」。
if err := os.MkdirAll(filepath.Join(cfg.KBDataDir, "approved"), 0o755); err == nil {
r.Static("/media", filepath.Join(cfg.KBDataDir, "approved"))
}
r.GET("/api/health", func(c *gin.Context) {
ver := "1.1.0"
if p, err := config.LoadPlatformConfig(); err == nil {
ver = p.Version
}
c.JSON(200, gin.H{"status": "ok", "service": "eaisalestrain-app", "version": ver})
})
auth := r.Group("/api/auth")
{
auth.POST("/login", Login)
auth.GET("/me", middleware.Auth(cfg), Me)
}
// 员工可读
r.GET("/api/company-train", middleware.Auth(cfg), GetCompanyTrain)
r.POST("/api/company-train/suggest-material", middleware.Auth(cfg), SuggestMaterial)
r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists)
r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), GetSpecialistByKey)
r.GET("/api/specialists/summary", middleware.Auth(cfg), SpecialistSummary)
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
r.GET("/api/products", middleware.Auth(cfg), ListProducts)
r.GET("/api/products/:id", middleware.Auth(cfg), GetProduct)
r.GET("/api/courses", middleware.Auth(cfg), ListCourses)
r.GET("/api/courses/:id", middleware.Auth(cfg), GetCourse)
// 学员端考试
r.GET("/api/exam/list", middleware.Auth(cfg), ExamList)
r.GET("/api/exam/cover", middleware.Auth(cfg), ExamCover)
r.POST("/api/exam/start", middleware.Auth(cfg), ExamStart)
r.POST("/api/exam/submit", middleware.Auth(cfg), ExamSubmit)
r.GET("/api/exam/record", middleware.Auth(cfg), ExamRecordList)
r.GET("/api/exam/record/:recordId", middleware.Auth(cfg), ExamRecordDetail)
r.GET("/api/exam/mistakes", middleware.Auth(cfg), MyMistakes)
r.PUT("/api/exam/mistakes/:id/resolve", middleware.Auth(cfg), ResolveMistake)
r.POST("/api/exam/mistakes/practice", middleware.Auth(cfg), MistakePractice)
r.GET("/api/exam/certificates", middleware.Auth(cfg), MyCertificates)
r.GET("/api/exam/certificates/:id", middleware.Auth(cfg), CertificateDetail)
// 学员:我的岗位应学清单 / 学习档案 / 积分与排行榜
r.GET("/api/my/position", middleware.Auth(cfg), MyPosition)
r.GET("/api/my/profile", middleware.Auth(cfg), MyProfile)
r.GET("/api/points/me", middleware.Auth(cfg), MyPoints)
r.GET("/api/points/leaderboard", middleware.Auth(cfg), Leaderboard)
// 站内消息通知
r.GET("/api/notifications", middleware.Auth(cfg), MyNotifications)
r.GET("/api/notifications/unread-count", middleware.Auth(cfg), UnreadNotificationCount)
r.PUT("/api/notifications/:id/read", middleware.Auth(cfg), MarkNotificationRead)
r.PUT("/api/notifications/read-all", middleware.Auth(cfg), MarkAllNotificationsRead)
// 素材媒体(员工/管理员均可上传、预览)
r.POST("/api/media/upload", middleware.Auth(cfg), Upload)
r.POST("/api/media/upload-init", middleware.Auth(cfg), UploadInit)
r.POST("/api/media/upload-chunk", middleware.Auth(cfg), UploadChunk)
r.POST("/api/media/upload-complete", middleware.Auth(cfg), UploadComplete)
r.GET("/api/media/preview/:mediaId", middleware.Auth(cfg), Preview)
r.GET("/api/media/status/:mediaId", middleware.Auth(cfg), MediaStatus)
r.GET("/api/knowledge/status/:sourceId", middleware.Auth(cfg), KnowledgeStatus)
// ── AI 对话(普通员工可访问,管理员访问)──
r.POST("/api/ai-chat/message", middleware.Auth(cfg), ChatMessage)
r.GET("/api/ai-chat/quick-actions", middleware.Auth(cfg), QuickActions)
r.POST("/api/ai-chat/quick-action", middleware.Auth(cfg), QuickAction)
r.GET("/api/ai/me", middleware.Auth(cfg), AIUsageMe)
r.GET("/api/ai/usage", middleware.Auth(cfg), AIUsage)
r.GET("/api/ai/routes/chat", ListChatRoutes)
r.GET("/api/ai/routes/embed", ListEmbedRoutes)
// 学习进度(员工上报 + 本人查询)
r.POST("/api/learning/progress", middleware.Auth(cfg), RecordLearningProgress)
r.GET("/api/learning/me", middleware.Auth(cfg), MyLearningProgress)
// 学习笔记(员工个人工具)
r.GET("/api/notes", middleware.Auth(cfg), ListNotes)
r.POST("/api/notes", middleware.Auth(cfg), CreateNote)
r.PUT("/api/notes/:id", middleware.Auth(cfg), UpdateNote)
r.DELETE("/api/notes/:id", middleware.Auth(cfg), DeleteNote)
// 管理员维护
admin := r.Group("/api")
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
{
admin.POST("/products", CreateProduct)
admin.PUT("/products/:id", UpdateProduct)
admin.DELETE("/products/:id", DeleteProduct)
admin.POST("/products/import", ImportProducts)
admin.POST("/courses", CreateCourse)
admin.PUT("/courses/:id", UpdateCourse)
admin.GET("/exam/questions", ListQuestions)
admin.POST("/exam/questions", CreateQuestion)
admin.PUT("/exam/questions/:id", UpdateQuestion)
admin.DELETE("/exam/questions/:id", DeleteQuestion)
admin.GET("/exam/papers", ListPapers)
admin.POST("/exam/papers", CreatePaper)
admin.PUT("/exam/papers/:id", UpdatePaper)
admin.DELETE("/exam/papers/:id", DeletePaper)
admin.GET("/media/audit-list", AuditList)
admin.POST("/media/audit/:mediaId", AuditMedia)
admin.POST("/knowledge/scan", KnowledgeScan)
admin.GET("/knowledge/audit-list", KnowledgeAuditList)
admin.POST("/knowledge/audit/:sourceId", KnowledgeAudit)
admin.GET("/knowledge/export", ExportKnowledge)
admin.GET("/positions", ListPositions)
admin.POST("/positions", CreatePosition)
admin.PUT("/positions/:id", UpdatePosition)
admin.DELETE("/positions/:id", DeletePosition)
admin.POST("/specialists", CreateSpecialist)
admin.PUT("/specialists/:id", UpdateSpecialist)
admin.DELETE("/specialists/:id", DeleteSpecialist)
admin.GET("/positions/:id/knowledge", ListPositionKnowledge)
admin.PUT("/positions/:id/knowledge", SavePositionKnowledge)
admin.GET("/positions/:id/blueprint", ListPositionBlueprint)
admin.PUT("/positions/:id/blueprint", SavePositionBlueprint)
admin.PUT("/users/:id/position", SetUserPosition)
admin.GET("/departments", ListDepartments)
admin.POST("/departments", CreateDepartment)
admin.PUT("/departments/:id", UpdateDepartment)
admin.DELETE("/departments/:id", DeleteDepartment)
admin.GET("/system/users", ListUsers)
admin.POST("/system/users", CreateUser)
admin.PUT("/system/users/:id", UpdateUser)
admin.GET("/system/users/:id/overview", UserOverview)
admin.GET("/system/exam-records", ListExamRecords)
admin.GET("/system/exam-records/export", ExportExamRecords)
admin.GET("/system/exam-records/:id", GetExamRecord)
admin.DELETE("/system/exam-records/:id", DeleteExamRecord)
admin.GET("/system/dashboard", AdminDashboard)
admin.GET("/system/exam-stats", ExamStats)
admin.GET("/system/exam-stats-by-position", PositionExamStats)
admin.GET("/system/department-stats", DepartmentStats)
admin.GET("/system/certificates", AdminCertificates)
admin.GET("/system/exam-coverage", ExamCoverage)
admin.GET("/system/learning-progress", AdminLearningProgress)
admin.GET("/system/config", GetConfig)
admin.PUT("/system/config", UpdateConfig)
admin.GET("/ai/config", GetAIConfig)
admin.PUT("/ai/config", PutAIConfig)
admin.POST("/ai/reload", ReloadAIConfig)
admin.GET("/ai/secrets-status", SecretsStatusHandler)
admin.GET("/ai/usage/users", AIUsageUsers)
}
}
@@ -0,0 +1,51 @@
package api
import (
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/web"
"github.com/gin-gonic/gin"
)
// routeItem 路由摘要(返回给前端选择器)
type routeItem struct {
ID string `json:"id"`
Provider string `json:"provider"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
Description string `json:"description"`
}
func toRouteItems(routes []*config.RouteConfig) []routeItem {
items := make([]routeItem, 0, len(routes))
for _, r := range routes {
items = append(items, routeItem{
ID: r.RouteID,
Provider: r.Provider,
Model: r.Model,
BaseURL: r.BaseURL,
Description: r.Description,
})
}
return items
}
// ListChatRoutes 返回可用 chat 路由列表(供前端选择器使用)
func ListChatRoutes(c *gin.Context) {
routes, err := config.GetRoutesByCategory("chat")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("路由加载失败: "+err.Error()))
return
}
web.OK(c, gin.H{"routes": toRouteItems(routes)})
}
// ListEmbedRoutes 返回可用 embedding 路由列表
func ListEmbedRoutes(c *gin.Context) {
routes, err := config.GetRoutesByCategory("embed")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("路由加载失败: "+err.Error()))
return
}
web.OK(c, gin.H{"routes": toRouteItems(routes)})
}
@@ -0,0 +1,342 @@
package api
import (
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ListSpecialists GET /api/specialists?tier=&state=&market_tag=
func ListSpecialists(c *gin.Context) {
q := store.DB.Model(&model.Specialist{})
if tier := c.Query("tier"); tier != "" {
q = q.Where("tier = ?", tier)
}
if marketTag := c.Query("market_tag"); marketTag != "" {
q = q.Where("market_tag = ?", marketTag)
}
user := middleware.CurrentUser(c)
isAdmin := user != nil && user.Role == "admin"
switch state := c.Query("state"); state {
case "":
q = q.Where("state = ?", "active")
case "all":
if !isAdmin {
q = q.Where("state = ?", "active")
}
default:
q = q.Where("state = ?", state)
}
var items []model.Specialist
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询专员目录失败"))
return
}
for i := range items {
store.EnsureSpecialistStructuredRecords(&items[i])
}
web.OK(c, items)
}
// GetSpecialistByKey GET /api/specialists/by-key/:key
func GetSpecialistByKey(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("专员 key 不能为空"))
return
}
q := store.DB.Model(&model.Specialist{}).Where("key = ?", key)
user := middleware.CurrentUser(c)
isAdmin := user != nil && user.Role == "admin"
if !isAdmin {
q = q.Where("state = ?", "active")
}
var item model.Specialist
if err := q.First(&item).Error; err != nil {
web.Fail(c, web.NewNotFoundError("专员不存在"))
return
}
store.EnsureSpecialistStructuredRecords(&item)
web.OK(c, item)
}
// SpecialistSummary GET /api/specialists/summary
func SpecialistSummary(c *gin.Context) {
type summary struct {
Total int64 `json:"total"`
Generic int64 `json:"generic"`
Industry int64 `json:"industry"`
DW int64 `json:"dw"`
ADW int64 `json:"adw"`
Upgradeable int64 `json:"upgradeable"`
Trial int64 `json:"trial"`
}
var resp summary
count := func(column string, value string) int64 {
var total int64
q := store.DB.Model(&model.Specialist{}).Where("state = ?", "active")
if column != "" {
q = q.Where(column+" = ?", value)
}
q.Count(&total)
return total
}
resp.Total = count("", "")
resp.Generic = count("tier", "generic")
resp.Industry = count("tier", "industry")
resp.DW = count("worker_type", "dw")
resp.ADW = count("worker_type", "adw")
resp.Upgradeable = count("market_tag", "可升级")
resp.Trial = count("market_tag", "试用")
web.OK(c, resp)
}
type specialistReq struct {
Key string `json:"key"`
Label string `json:"label"`
Tier string `json:"tier"`
WorkerType string `json:"worker_type"`
Route string `json:"route"`
Summary string `json:"summary"`
WorkStatus string `json:"work_status"`
RiskLabel string `json:"risk_label"`
Color string `json:"color"`
Stage string `json:"stage"`
Progress int `json:"progress"`
MarketTag string `json:"market_tag"`
Version string `json:"version"`
ConnectorScope string `json:"connector_scope"`
PermissionScope string `json:"permission_scope"`
ResourceBindings string `json:"resource_bindings"`
InfoSources string `json:"info_sources"`
BaseSkills string `json:"base_skills"`
AIAssistance string `json:"ai_assistance"`
GeneratedSkills string `json:"generated_skills"`
InputsRecordsJSON string `json:"inputs_records_json"`
LegacySourceRecordsJSON string `json:"source_records_json"`
PermissionRecordsJSON string `json:"permission_records_json"`
ActionRecordsJSON string `json:"action_records_json"`
ResultRecordsJSON string `json:"result_records_json"`
State string `json:"state"`
SortOrder int `json:"sort_order"`
}
func normalizeSpecialistReq(req *specialistReq) {
req.Key = strings.TrimSpace(req.Key)
req.Label = strings.TrimSpace(req.Label)
req.Tier = strings.TrimSpace(req.Tier)
req.WorkerType = strings.TrimSpace(req.WorkerType)
req.Route = strings.TrimSpace(req.Route)
req.Summary = strings.TrimSpace(req.Summary)
req.WorkStatus = strings.TrimSpace(req.WorkStatus)
req.RiskLabel = strings.TrimSpace(req.RiskLabel)
req.Color = strings.TrimSpace(req.Color)
req.Stage = strings.TrimSpace(req.Stage)
req.MarketTag = strings.TrimSpace(req.MarketTag)
req.Version = strings.TrimSpace(req.Version)
req.ConnectorScope = strings.TrimSpace(req.ConnectorScope)
req.PermissionScope = strings.TrimSpace(req.PermissionScope)
req.ResourceBindings = strings.TrimSpace(req.ResourceBindings)
req.InfoSources = strings.TrimSpace(req.InfoSources)
req.BaseSkills = strings.TrimSpace(req.BaseSkills)
req.AIAssistance = strings.TrimSpace(req.AIAssistance)
req.GeneratedSkills = strings.TrimSpace(req.GeneratedSkills)
req.InputsRecordsJSON = strings.TrimSpace(req.InputsRecordsJSON)
req.LegacySourceRecordsJSON = strings.TrimSpace(req.LegacySourceRecordsJSON)
if req.InputsRecordsJSON == "" {
req.InputsRecordsJSON = req.LegacySourceRecordsJSON
}
req.PermissionRecordsJSON = strings.TrimSpace(req.PermissionRecordsJSON)
req.ActionRecordsJSON = strings.TrimSpace(req.ActionRecordsJSON)
req.ResultRecordsJSON = strings.TrimSpace(req.ResultRecordsJSON)
req.State = strings.TrimSpace(req.State)
}
func validateSpecialistReq(req *specialistReq) *web.AppError {
normalizeSpecialistReq(req)
if req.Key == "" || req.Label == "" || req.Tier == "" || req.Route == "" {
return web.NewBadRequest("key、label、tier、route 为必填")
}
if req.Tier != "generic" && req.Tier != "industry" {
return web.NewBadRequest("tier 只能是 generic 或 industry")
}
if req.WorkerType == "" {
req.WorkerType = "dw"
}
if req.WorkerType != "dw" && req.WorkerType != "adw" {
return web.NewBadRequest("worker_type 只能是 dw 或 adw")
}
if req.State == "" {
req.State = "active"
}
if req.State != "active" && req.State != "inactive" {
return web.NewBadRequest("state 只能是 active 或 inactive")
}
if req.MarketTag == "" {
req.MarketTag = "已安装"
}
if req.Progress < 0 || req.Progress > 100 {
return web.NewBadRequest("progress 必须在 0 到 100 之间")
}
if !store.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) {
return web.NewBadRequest("inputs_records_json 必须是 JSON 数组")
}
if !store.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) {
return web.NewBadRequest("permission_records_json 必须是 JSON 数组")
}
if !store.ValidateStructuredRecordsJSON(req.ActionRecordsJSON) {
return web.NewBadRequest("action_records_json 必须是 JSON 数组")
}
if !store.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) {
return web.NewBadRequest("result_records_json 必须是 JSON 数组")
}
return nil
}
// CreateSpecialist POST /api/specialists (admin)
func CreateSpecialist(c *gin.Context) {
var req specialistReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateSpecialistReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
var count int64
store.DB.Model(&model.Specialist{}).Where("key = ?", req.Key).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("专员 key 已存在"))
return
}
item := model.Specialist{
Key: req.Key,
Label: req.Label,
Tier: req.Tier,
WorkerType: req.WorkerType,
Route: req.Route,
Summary: req.Summary,
WorkStatus: req.WorkStatus,
RiskLabel: req.RiskLabel,
Color: req.Color,
Stage: req.Stage,
Progress: req.Progress,
MarketTag: req.MarketTag,
Version: req.Version,
ConnectorScope: req.ConnectorScope,
PermissionScope: req.PermissionScope,
ResourceBindings: req.ResourceBindings,
InfoSources: req.InfoSources,
BaseSkills: req.BaseSkills,
AIAssistance: req.AIAssistance,
GeneratedSkills: req.GeneratedSkills,
InputsRecordsJSON: req.InputsRecordsJSON,
PermissionRecordsJSON: req.PermissionRecordsJSON,
ActionRecordsJSON: req.ActionRecordsJSON,
ResultRecordsJSON: req.ResultRecordsJSON,
State: req.State,
SortOrder: req.SortOrder,
}
store.EnsureSpecialistStructuredRecords(&item)
if err := store.DB.Create(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建专员失败"))
return
}
web.OK(c, item)
}
// UpdateSpecialist PUT /api/specialists/:id (admin)
func UpdateSpecialist(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.Specialist
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("专员不存在"))
return
}
var req specialistReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateSpecialistReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
var count int64
store.DB.Model(&model.Specialist{}).Where("key = ? AND id <> ?", req.Key, id).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("专员 key 已存在"))
return
}
item.Key = req.Key
item.Label = req.Label
item.Tier = req.Tier
item.WorkerType = req.WorkerType
item.Route = req.Route
item.Summary = req.Summary
item.WorkStatus = req.WorkStatus
item.RiskLabel = req.RiskLabel
item.Color = req.Color
item.Stage = req.Stage
item.Progress = req.Progress
item.MarketTag = req.MarketTag
item.Version = req.Version
item.ConnectorScope = req.ConnectorScope
item.PermissionScope = req.PermissionScope
item.ResourceBindings = req.ResourceBindings
item.InfoSources = req.InfoSources
item.BaseSkills = req.BaseSkills
item.AIAssistance = req.AIAssistance
item.GeneratedSkills = req.GeneratedSkills
item.InputsRecordsJSON = req.InputsRecordsJSON
item.PermissionRecordsJSON = req.PermissionRecordsJSON
item.ActionRecordsJSON = req.ActionRecordsJSON
item.ResultRecordsJSON = req.ResultRecordsJSON
item.State = req.State
item.SortOrder = req.SortOrder
store.EnsureSpecialistStructuredRecords(&item)
if err := store.DB.Save(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新专员失败"))
return
}
web.OK(c, item)
}
// DeleteSpecialist DELETE /api/specialists/:id (admin)
func DeleteSpecialist(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.Specialist
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("专员不存在"))
return
}
if err := store.DB.Delete(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除专员失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
+384
View File
@@ -0,0 +1,384 @@
package api
import (
"math"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// round1 保留 1 位小数
func round1(f float64) float64 {
return math.Round(f*10) / 10
}
// examSummary 成绩汇总(传入某集合的 exam_record)
func examSummary(recs []model.ExamRecord) gin.H {
s := gin.H{"total": 0, "passed": 0, "failed": 0, "pass_rate": 0.0, "avg_score": 0.0, "max_score": 0, "min_score": 0}
if len(recs) == 0 {
return s
}
passed, sum, maxS, minS := 0, 0, -1, 1000000
for _, r := range recs {
if r.Passed {
passed++
}
sum += r.Score
if r.Score > maxS {
maxS = r.Score
}
if r.Score < minS {
minS = r.Score
}
}
s["total"] = len(recs)
s["passed"] = passed
s["failed"] = len(recs) - passed
s["pass_rate"] = round1(float64(passed) * 100 / float64(len(recs)))
s["avg_score"] = round1(float64(sum) / float64(len(recs)))
s["max_score"] = maxS
s["min_score"] = minS
return s
}
// AdminDashboard GET /api/system/dashboard —— 管理员首页运营指标
func AdminDashboard(c *gin.Context) {
var employeeCount, adminCount, productCount, courseCount, questionCount, formalPaperCount int64
var pendingMedia, pendingKnowledge, recentUsers int64
store.DB.Model(&model.User{}).Where("role = ?", "employee").Count(&employeeCount)
store.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
store.DB.Model(&model.Product{}).Where("status = ?", "active").Count(&productCount)
store.DB.Model(&model.Course{}).Where("status = ?", "active").Count(&courseCount)
store.DB.Model(&model.Question{}).Where("status = ?", "active").Count(&questionCount)
store.DB.Model(&model.ExamPaper{}).Where("type = ? AND status = ?", "formal", "active").Count(&formalPaperCount)
store.DB.Model(&model.MediaFile{}).Where("status = ?", "pending").Count(&pendingMedia)
store.DB.Model(&model.KnowledgeSource{}).Where("audit_status = ?", "pending").Count(&pendingKnowledge)
store.DB.Model(&model.User{}).Where("created_at >= ?", time.Now().AddDate(0, 0, -7)).Count(&recentUsers)
// 近 7 天每日新增(首页迷你柱状图数据源)
now := time.Now()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6)
var newRows []struct {
Date string `gorm:"column:d"`
Count int64 `gorm:"column:c"`
}
store.DB.Raw(`SELECT strftime('%Y-%m-%d', created_at) AS d, COUNT(*) AS c
FROM user WHERE created_at >= ? GROUP BY d`, dayStart).Scan(&newRows)
newByDay := map[string]int64{}
for _, r := range newRows {
newByDay[r.Date] = r.Count
}
newUsers7d := make([]gin.H, 0, 7)
for i := 0; i < 7; i++ {
d := dayStart.AddDate(0, 0, i).Format("2006-01-02")
newUsers7d = append(newUsers7d, gin.H{"date": d, "count": newByDay[d]})
}
var recs []model.ExamRecord
store.DB.Find(&recs)
web.OK(c, gin.H{
"employee_count": employeeCount,
"admin_count": adminCount,
"product_count": productCount,
"course_count": courseCount,
"question_count": questionCount,
"formal_paper_count": formalPaperCount,
"pending_material_count": pendingMedia + pendingKnowledge,
"recent_new_users": recentUsers,
"new_users_7d": newUsers7d,
"exam": examSummary(recs),
})
}
// ExamStats GET /api/system/exam-stats —— 成绩统计(汇总 + 按考试 + 分数段)
func ExamStats(c *gin.Context) {
var recs []model.ExamRecord
store.DB.Order("submitted_at DESC").Find(&recs)
// 按 paper 聚合
var papers []model.ExamPaper
store.DB.Order("id ASC").Find(&papers)
paperName := map[uint]string{}
paperType := map[uint]string{}
for _, p := range papers {
paperName[p.ID] = p.Name
paperType[p.ID] = p.Type
}
type paperAgg struct {
PaperID uint `json:"paper_id"`
ExamName string `json:"exam_name"`
Type string `json:"type"`
TakenCount int `json:"taken_count"`
Passed int `json:"passed_count"`
PassRate float64 `json:"pass_rate"`
AvgScore float64 `json:"avg_score"`
}
pmap := map[uint]*paperAgg{}
for _, r := range recs {
a := pmap[r.PaperID]
if a == nil {
a = &paperAgg{PaperID: r.PaperID, ExamName: paperName[r.PaperID], Type: paperType[r.PaperID]}
if a.ExamName == "" {
a.ExamName = r.ExamName
}
pmap[r.PaperID] = a
}
a.TakenCount++
if r.Passed {
a.Passed++
}
}
papersOut := make([]paperAgg, 0, len(pmap))
for _, a := range pmap {
a.PassRate = round1(float64(a.Passed) * 100 / float64(a.TakenCount))
a.AvgScore = round1(avgScoreOf(recs, a.PaperID))
papersOut = append(papersOut, *a)
}
// 分数段分布
bands := []string{"0-59", "60-69", "70-79", "80-89", "90-100"}
bandCount := map[string]int{}
for _, b := range bands {
bandCount[b] = 0
}
for _, r := range recs {
bandCount[scoreBand(r.Score)]++
}
distribution := make([]gin.H, 0, len(bands))
for _, b := range bands {
distribution = append(distribution, gin.H{"band": b, "count": bandCount[b]})
}
web.OK(c, gin.H{
"summary": examSummary(recs),
"papers": papersOut,
"distribution": distribution,
})
}
func avgScoreOf(recs []model.ExamRecord, paperID uint) float64 {
sum, n := 0, 0
for _, r := range recs {
if r.PaperID == paperID {
sum += r.Score
n++
}
}
if n == 0 {
return 0
}
return float64(sum) / float64(n)
}
func scoreBand(score int) string {
switch {
case score < 60:
return "0-59"
case score < 70:
return "60-69"
case score < 80:
return "70-79"
case score < 90:
return "80-89"
default:
return "90-100"
}
}
// ExamCoverage GET /api/system/exam-coverage —— 正式考试覆盖度(缺考名单)
func ExamCoverage(c *gin.Context) {
var papers []model.ExamPaper
store.DB.Where("type = ? AND status = ?", "formal", "active").Order("id ASC").Find(&papers)
var employees []model.User
store.DB.Where("role = ? AND status = ?", "employee", "active").Order("id ASC").Find(&employees)
var recs []model.ExamRecord
store.DB.Order("submitted_at DESC").Find(&recs)
// 每人每场取最新一次(正式考单次,取首条即可)
type key struct{ uid, pid uint }
lmap := map[key]model.ExamRecord{}
for _, r := range recs {
k := key{r.UserID, r.PaperID}
if _, ok := lmap[k]; !ok {
lmap[k] = r
}
}
papersOut := make([]gin.H, 0, len(papers))
for _, p := range papers {
var taken []gin.H
var pending []gin.H
for _, e := range employees {
r, ok := lmap[key{e.ID, p.ID}]
if ok {
taken = append(taken, gin.H{
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
"department": e.Department, "score": r.Score, "passed": r.Passed,
"submitted_at": r.SubmittedAt,
})
} else {
pending = append(pending, gin.H{
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
"department": e.Department,
})
}
}
papersOut = append(papersOut, gin.H{
"paper_id": p.ID,
"exam_name": p.Name,
"employee_count": len(employees),
"taken_count": len(taken),
"pending_count": len(pending),
"taken": taken,
"pending": pending,
})
}
web.OK(c, gin.H{"papers": papersOut})
}
// PositionExamStats GET /api/system/exam-stats-by-position —— 成绩按岗位聚合
func PositionExamStats(c *gin.Context) {
var positions []model.Position
store.DB.Where("status = ?", "active").Order("id ASC").Find(&positions)
var users []model.User
store.DB.Find(&users)
userPos := map[uint]uint{} // user_id -> position_id
posEmployee := map[uint]int{} // position_id -> 员工数
for _, u := range users {
if u.PositionID == nil {
continue
}
userPos[u.ID] = *u.PositionID
posEmployee[*u.PositionID]++
}
type posAgg struct {
PositionID uint `json:"position_id"`
PositionName string `json:"position_name"`
EmployeeCount int `json:"employee_count"`
TakenCount int `json:"taken_count"`
PassedCount int `json:"passed_count"`
PassRate float64 `json:"pass_rate"`
AvgScore float64 `json:"avg_score"`
}
agg := map[uint]*posAgg{}
for _, p := range positions {
agg[p.ID] = &posAgg{PositionID: p.ID, PositionName: p.Name, EmployeeCount: posEmployee[p.ID]}
}
scoreSum := map[uint]int{}
var recs []model.ExamRecord
store.DB.Find(&recs)
for _, r := range recs {
pid, ok := userPos[r.UserID]
if !ok {
continue
}
a := agg[pid]
if a == nil {
a = &posAgg{PositionID: pid}
agg[pid] = a
}
a.TakenCount++
if r.Passed {
a.PassedCount++
}
scoreSum[pid] += r.Score
}
out := make([]posAgg, 0, len(agg))
for _, p := range positions {
a := agg[p.ID]
if a == nil {
continue
}
if a.TakenCount > 0 {
a.PassRate = round1(float64(a.PassedCount) * 100 / float64(a.TakenCount))
a.AvgScore = round1(float64(scoreSum[p.ID]) / float64(a.TakenCount))
}
out = append(out, *a)
}
web.OK(c, gin.H{"items": out})
}
// UserOverview GET /api/system/users/{id}/overview —— 员工综合画像(考试 + AI 用量 + 学习进度)
func UserOverview(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var u model.User
if err := store.DB.First(&u, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("用户不存在"))
return
}
var recs []model.ExamRecord
store.DB.Where("user_id = ?", id).Order("submitted_at DESC").Find(&recs)
type aiAgg struct {
Calls int64 `json:"calls"`
Success int64 `json:"success"`
Credits int64 `json:"credits"`
}
var ai aiAgg
store.DB.Raw(`SELECT COUNT(*) AS calls,
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success,
COALESCE(SUM(credits_charged), 0) AS credits
FROM ai_call_log WHERE user_id = ?`, id).Scan(&ai)
var lps []model.LearningProgress
store.DB.Where("user_id = ?", id).Order("updated_at DESC").Find(&lps)
companyViewed := false
productCount, courseCount := 0, 0
var lastViewed *time.Time
for _, lp := range lps {
switch lp.ItemType {
case "company":
companyViewed = true
case "product":
productCount++
case "course":
courseCount++
}
if lastViewed == nil || lp.UpdatedAt.After(*lastViewed) {
t := lp.UpdatedAt
lastViewed = &t
}
}
web.OK(c, gin.H{
"user": gin.H{
"id": u.ID, "username": u.Username, "full_name": u.FullName,
"role": u.Role, "status": u.Status, "ai_points": u.AiPoints,
"department": u.Department, "position": u.Position, "hire_batch": u.HireBatch,
"created_at": u.CreatedAt,
},
"exam": gin.H{
"summary": examSummary(recs),
"records": recs,
},
"ai_usage": gin.H{
"total_calls": ai.Calls,
"success_calls": ai.Success,
"total_credits": ai.Credits,
"ai_points_left": u.AiPoints,
},
"learning": gin.H{
"company_viewed": companyViewed,
"product_count": productCount,
"course_count": courseCount,
"total_items": len(lps),
"last_viewed_at": lastViewed,
},
})
}
@@ -0,0 +1,348 @@
package api
import (
"encoding/csv"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ============ 用户管理(管理员) ============
// ListUsers GET /api/system/users —— 含考试统计(考试次数/通过数/最近成绩)
func ListUsers(c *gin.Context) {
var users []model.User
if err := store.DB.Order("id ASC").Find(&users).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询用户失败"))
return
}
var recs []model.ExamRecord
store.DB.Order("submitted_at DESC").Find(&recs)
type stat struct {
ExamCount int
PassedCount int
LatestScore *int
LatestPassed *bool
LatestExamName string
LatestSubmittedAt *time.Time
}
stats := map[uint]*stat{}
for _, r := range recs {
s := stats[r.UserID]
if s == nil {
s = &stat{}
stats[r.UserID] = s
}
s.ExamCount++
if r.Passed {
s.PassedCount++
}
if s.LatestSubmittedAt == nil {
sc, ps := r.Score, r.Passed
t := r.SubmittedAt
s.LatestScore, s.LatestPassed = &sc, &ps
s.LatestExamName = r.ExamName
s.LatestSubmittedAt = &t
}
}
out := make([]gin.H, 0, len(users))
for _, u := range users {
h := gin.H{
"id": u.ID, "username": u.Username, "full_name": u.FullName,
"role": u.Role, "status": u.Status, "ai_points": u.AiPoints,
"department": u.Department, "position": u.Position, "hire_batch": u.HireBatch,
"position_id": u.PositionID,
"created_at": u.CreatedAt,
"exam_count": 0, "passed_count": 0,
"latest_score": nil, "latest_passed": nil, "latest_exam_name": "", "latest_submitted_at": nil,
}
if s := stats[u.ID]; s != nil {
h["exam_count"] = s.ExamCount
h["passed_count"] = s.PassedCount
h["latest_score"] = s.LatestScore
h["latest_passed"] = s.LatestPassed
h["latest_exam_name"] = s.LatestExamName
h["latest_submitted_at"] = s.LatestSubmittedAt
}
out = append(out, h)
}
web.OK(c, out)
}
// CreateUser POST /api/system/users —— {username,password,full_name,role,department,position,hire_batch}
func CreateUser(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
FullName string `json:"full_name"`
Role string `json:"role"`
Department string `json:"department"`
Position string `json:"position"`
HireBatch string `json:"hire_batch"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" || req.FullName == "" {
web.Fail(c, web.NewBadRequest("username/password/full_name 必填"))
return
}
if req.Role != "admin" && req.Role != "employee" {
req.Role = "employee"
}
var count int64
store.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
web.Fail(c, web.NewConflictError("用户名已存在"))
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
web.Fail(c, web.NewBadRequest("密码加密失败"))
return
}
u := model.User{
Username: req.Username, PasswordHash: hash, FullName: req.FullName,
Role: req.Role, Status: "active", AiPoints: defaultAiPoints(),
Department: req.Department, Position: req.Position, HireBatch: req.HireBatch,
}
if req.Role == "admin" {
u.AiPoints = 999999
}
if err := store.DB.Create(&u).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建用户失败"))
return
}
web.OK(c, u)
}
// UpdateUser PUT /api/system/users/{id} —— 编辑(可选改密/禁用/改角色)
func UpdateUser(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var u model.User
if err := store.DB.First(&u, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("用户不存在"))
return
}
var req struct {
FullName string `json:"full_name"`
Role string `json:"role"`
Status string `json:"status"`
Password string `json:"password"`
AiPoints *int `json:"ai_points"`
Department string `json:"department"`
Position string `json:"position"`
HireBatch string `json:"hire_batch"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.FullName != "" {
u.FullName = req.FullName
}
if req.Role == "admin" || req.Role == "employee" {
u.Role = req.Role
}
if req.Status == "active" || req.Status == "disabled" {
u.Status = req.Status
}
if req.Department != "" {
u.Department = req.Department
}
if req.Position != "" {
u.Position = req.Position
}
if req.HireBatch != "" {
u.HireBatch = req.HireBatch
}
if req.Password != "" {
hash, err := auth.HashPassword(req.Password)
if err != nil {
web.Fail(c, web.NewBadRequest("密码加密失败"))
return
}
u.PasswordHash = hash
}
if req.AiPoints != nil {
if *req.AiPoints < 0 {
web.Fail(c, web.NewBadRequest("ai_points 不能为负"))
return
}
u.AiPoints = *req.AiPoints
}
if err := store.DB.Save(&u).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新用户失败"))
return
}
web.OK(c, u)
}
// defaultAiPoints 读取新用户默认 AI 算力点(system_config.ai_points_default,缺省 100)
func defaultAiPoints() int {
var sc model.SystemConfig
if err := store.DB.Where("config_key = ?", "ai_points_default").First(&sc).Error; err == nil {
if v, err := strconv.Atoi(strings.TrimSpace(sc.ConfigValue)); err == nil {
return v
}
}
return 100
}
// ============ 成绩管理(管理员) ============
// ListExamRecords GET /api/system/exam-records?user_id=&paper_id=
func ListExamRecords(c *gin.Context) {
q := store.DB.Model(&model.ExamRecord{})
if uid := c.Query("user_id"); uid != "" {
q = q.Where("user_id = ?", uid)
}
if pid := c.Query("paper_id"); pid != "" {
q = q.Where("paper_id = ?", pid)
}
var items []model.ExamRecord
if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询成绩失败"))
return
}
web.OK(c, items)
}
// GetExamRecord GET /api/system/exam-records/{id} —— 详情
func GetExamRecord(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var rec model.ExamRecord
if err := store.DB.First(&rec, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("考试记录不存在"))
return
}
var detail any
_ = json.Unmarshal([]byte(rec.DetailJSON), &detail)
web.OK(c, gin.H{
"id": rec.ID, "user_id": rec.UserID, "paper_id": rec.PaperID, "exam_name": rec.ExamName,
"score": rec.Score, "total_score": rec.TotalScore, "pass_score": rec.PassScore, "passed": rec.Passed,
"correct_count": rec.CorrectCount, "wrong_count": rec.WrongCount,
"detail": detail, "submitted_at": rec.SubmittedAt,
})
}
// DeleteExamRecord DELETE /api/system/exam-records/{id} —— 删除成绩记录(用于重置正式考重考资格)
func DeleteExamRecord(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var rec model.ExamRecord
if err := store.DB.First(&rec, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("考试记录不存在"))
return
}
if err := store.DB.Delete(&rec).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除考试记录失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
// ExportExamRecords GET /api/system/exam-records/export —— 导出 CSV(支持 user_id/paper_id 过滤)
func ExportExamRecords(c *gin.Context) {
q := store.DB.Model(&model.ExamRecord{})
if uid := c.Query("user_id"); uid != "" {
q = q.Where("user_id = ?", uid)
}
if pid := c.Query("paper_id"); pid != "" {
q = q.Where("paper_id = ?", pid)
}
var items []model.ExamRecord
if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询成绩失败"))
return
}
// 用户名映射
var users []model.User
store.DB.Find(&users)
nameMap := map[uint]model.User{}
for _, u := range users {
nameMap[u.ID] = u
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="exam_records.csv"`)
c.Writer.WriteString("\xEF\xBB\xBF") // UTF-8 BOM,兼容 Excel
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"编号", "用户名", "姓名", "部门", "考试名称", "得分", "总分", "结果", "答对", "答错", "提交时间"})
for _, r := range items {
u := nameMap[r.UserID]
passed := "未通过"
if r.Passed {
passed = "通过"
}
_ = w.Write([]string{
strconv.FormatUint(uint64(r.ID), 10),
u.Username,
u.FullName,
u.Department,
r.ExamName,
strconv.Itoa(r.Score),
strconv.Itoa(r.TotalScore),
passed,
strconv.Itoa(r.CorrectCount),
strconv.Itoa(r.WrongCount),
r.SubmittedAt.Format("2006-01-02 15:04:05"),
})
}
w.Flush()
}
// ============ 系统参数配置(管理员) ============
// GetConfig GET /api/system/config —— 所有系统参数
func GetConfig(c *gin.Context) {
var items []model.SystemConfig
store.DB.Order("id ASC").Find(&items)
type cfgItem struct {
Key string `json:"config_key"`
Value string `json:"config_value"`
Description string `json:"description"`
}
out := make([]cfgItem, 0, len(items))
for _, it := range items {
out = append(out, cfgItem{Key: it.ConfigKey, Value: it.ConfigValue, Description: it.Description})
}
web.OK(c, gin.H{"configs": out})
}
// UpdateConfig PUT /api/system/config —— {configs: {key: value}}
func UpdateConfig(c *gin.Context) {
var req struct {
Configs map[string]string `json:"configs"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.Configs) == 0 {
web.Fail(c, web.NewBadRequest("configs 必填"))
return
}
for k, v := range req.Configs {
var sc model.SystemConfig
if err := store.DB.Where("config_key = ?", k).First(&sc).Error; err == nil {
sc.ConfigValue = v
store.DB.Save(&sc)
} else {
store.DB.Create(&model.SystemConfig{ConfigKey: k, ConfigValue: v})
}
}
web.OK(c, gin.H{"updated": len(req.Configs)})
}
@@ -0,0 +1,58 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// HashPassword bcrypt 哈希
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
// VerifyPassword 校验密码
func VerifyPassword(password, hashed string) bool {
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) == nil
}
// CreateToken 签发 JWT(HS256),payload 对齐 Python:sub/role/iat/exp
func CreateToken(username, role, secret string, expireMin int) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"sub": username,
"role": role,
"iat": now.Unix(),
"exp": now.Add(time.Duration(expireMin) * time.Minute).Unix(),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
// SignClaims 通用 JWT 签发(考试会话等复用)
func SignClaims(claims jwt.MapClaims, secret string) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
// ParseToken 校验 JWT,返回 claims。失败返回 error。
func ParseToken(tokenStr, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
return claims, nil
}
@@ -0,0 +1,107 @@
package config
import (
"os"
"path/filepath"
"strconv"
)
// Config 全局配置,从环境变量读取(带默认值)。
// system_config 表中的值在运行时覆盖对应字段(见 service)。
type Config struct {
Port string
DBPath string // SQLite 文件路径
JWTSecret string
JWTExpireMin int
LLMBaseURL string
LLMAPIKey string
LLMModel string
EmbedModel string
KBDataDir string
KnowledgeSourceDir string
FileMaxDoc int64
FileMaxVideo int64
ChunkThreshold int64
LibreOfficeBin string
PdftotextBin string
KingdeeBaseURL string
KingdeeAccountID string
KingdeeUsername string
KingdeePassword string
KingdeeLCID int
KingdeeTimeoutSec int
}
// Load 读取环境变量构造配置。缺失的用默认值(部署时通过 .env / systemd 注入)。
func Load() *Config {
baseDir := backendBaseDir()
return &Config{
Port: getenv("PORT", "8080"),
DBPath: getenv("DB_PATH", filepath.Join(baseDir, "data", "eaisalestrain.db")),
JWTSecret: getenv("JWT_SECRET", "change-this-to-a-strong-secret-in-production"),
JWTExpireMin: getenvInt("JWT_EXPIRE_MINUTES", 480),
LLMBaseURL: getenv("LLM_BASE_URL", "http://127.0.0.1:11434/v1"),
LLMAPIKey: getenv("LLM_API_KEY", "ollama"),
LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"),
EmbedModel: getenv("EMBED_MODEL", "bge-m3"),
KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")),
KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")),
FileMaxDoc: getenvInt64("FILE_MAX_SIZE_DOC", 209715200),
FileMaxVideo: getenvInt64("FILE_MAX_SIZE_VIDEO", 2147483648),
ChunkThreshold: getenvInt64("CHUNK_THRESHOLD", 104857600),
LibreOfficeBin: getenv("LIBREOFFICE_BIN", "libreoffice"),
PdftotextBin: getenv("PDFTOTEXT_BIN", "pdftotext"),
KingdeeBaseURL: getenv("KINGDEE_BASE_URL", ""),
KingdeeAccountID: getenv("KINGDEE_ACCOUNT_ID", ""),
KingdeeUsername: getenv("KINGDEE_USERNAME", ""),
KingdeePassword: getenv("KINGDEE_PASSWORD", ""),
KingdeeLCID: getenvInt("KINGDEE_LCID", 2052),
KingdeeTimeoutSec: getenvInt("KINGDEE_TIMEOUT_SEC", 15),
}
}
func backendBaseDir() string {
if exe, err := os.Executable(); err == nil && exe != "" {
return filepath.Clean(filepath.Join(filepath.Dir(exe), ".."))
}
if wd, err := os.Getwd(); err == nil && wd != "" {
return wd
}
return "."
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getenvInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getenvInt64(key string, def int64) int64 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
}
return def
}
@@ -0,0 +1,552 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// ──────────────────────────────────────────────
// 核心类型
// ──────────────────────────────────────────────
// RouteInfo 单条路由定义(JSON 文件中的原始数据)
type RouteInfo struct {
Provider string `json:"provider"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
Category string `json:"category,omitempty"`
Description string `json:"description,omitempty"`
}
// RouteConfig 运行时完整路由配置(合并 secrets 后)
type RouteConfig struct {
RouteID string
Provider string
Model string
BaseURL string // 完整 base_url(已从 secrets 注入)
Endpoint string // 相对路径(如 /chat/completions)
FullURL string // BaseURL + Endpoint
APIKey string
MaxTokens int
Temperature float64
Category string // chat / embed / image
Description string
}
// AIConfig 顶层结构(支持分类路由)
type AIConfig struct {
Version string `json:"version"`
Description string `json:"description"`
DefaultRoute string `json:"default_route"`
DefaultEmbedRoute string `json:"default_embed_route"`
AgentRoutes map[string]string `json:"agent_routes"`
ChatRoutes map[string]RouteInfo `json:"chat_routes"`
EmbedRoutes map[string]RouteInfo `json:"embed_routes"`
ImageRoutes map[string]RouteInfo `json:"image_routes"`
FallbackRoutes map[string][]string `json:"fallback_routes"`
// 兼容旧版平铺 routes(若有则回退)
Routes map[string]RouteInfo `json:"routes,omitempty"`
}
// AISecrets AI 密钥文件结构(从 pj034 直接复制的格式)
type AISecrets struct {
VECTORENGINE_API_KEY string `json:"VECTORENGINE_API_KEY,omitempty"`
OPENROUTER_API_KEY string `json:"OPENROUTER_API_KEY,omitempty"`
VOLCES_API_KEY string `json:"VOLCES_API_KEY,omitempty"`
LMUAI_API_KEY string `json:"LMUAI_API_KEY,omitempty"`
ALIYUN_API_KEY string `json:"ALIYUN_API_KEY,omitempty"`
DEEPSEEK_API_KEY string `json:"DEEPSEEK_API_KEY,omitempty"`
REMOVE_BG_API_KEY string `json:"REMOVE_BG_API_KEY,omitempty"`
ANTHROPIC_API_KEY string `json:"ANTHROPIC_API_KEY,omitempty"`
OPENAI_API_KEY string `json:"OPENAI_API_KEY,omitempty"`
BRAVE_SEARCH_API_KEY string `json:"BRAVE_SEARCH_API_KEY,omitempty"`
}
// PlatformConfig 平台静态配置
type PlatformConfig struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Version string `json:"version"`
BuildTime string `json:"build_time"`
Company string `json:"company"`
Description string `json:"description"`
}
// ──────────────────────────────────────────────
// PROVIDER_SECRET_KEY 映射(参考 pj034 ai_config.py)
// provider 名称 → secrets JSON 中的字段名
// ──────────────────────────────────────────────
var ProviderSecretKey = map[string]string{
"vectorengine": "VECTORENGINE_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"volces": "VOLCES_API_KEY",
"lmuai": "LMUAI_API_KEY",
"aliyun": "ALIYUN_API_KEY",
"pic_copilot": "ALIYUN_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"remove_bg": "REMOVE_BG_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
}
// ProviderDefaultBaseURL provider 默认 base_url(当 secrets 未提供时)
var ProviderDefaultBaseURL = map[string]string{
"ollama": "http://127.0.0.1:11434/v1",
"openrouter": "https://openrouter.ai/api/v1",
"openai": "https://api.openai.com/v1",
"vectorengine": "https://api.vectorengine.ai/v1",
"volces": "https://ark.cn-beijing.volces.com/api/v3",
"lmuai": "https://api.lmuai.com/v1",
}
// ──────────────────────────────────────────────
// 文件路径解析
// ──────────────────────────────────────────────
var (
mu sync.RWMutex
cachedAICfg *AIConfig
cachedSecrets *AISecrets
cachedPlatform *PlatformConfig
)
// configDir 寻找 config/ 目录
func configDir() string {
// 优先使用可执行文件所在后端目录下的 config/
exe, _ := os.Executable()
if exe != "" {
exeDir := filepath.Dir(exe)
candidates := []string{
filepath.Join(exeDir, "config"),
filepath.Join(exeDir, "..", "config"),
}
for _, d := range candidates {
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
return filepath.Clean(d)
}
}
}
// 回退 CWD 下的 config/
if fi, err := os.Stat("config"); err == nil && fi.IsDir() {
return filepath.Clean("config")
}
// 再回退 deploy/
if fi, err := os.Stat("deploy"); err == nil && fi.IsDir() {
return filepath.Clean("deploy")
}
return ""
}
func resolveConfigPath(name string) string {
if d := configDir(); d != "" {
return filepath.Join(d, name)
}
return name
}
func loadJSON(path string, dst any) error {
p := resolveConfigPath(path)
data, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("读取 %s 失败: %w", p, err)
}
if err := json.Unmarshal(data, dst); err != nil {
return fmt.Errorf("解析 %s 失败: %w", p, err)
}
return nil
}
// ──────────────────────────────────────────────
// 公开加载函数
// ──────────────────────────────────────────────
func LoadAIConfig(forceReload ...bool) (*AIConfig, error) {
fr := len(forceReload) > 0 && forceReload[0]
if !fr {
mu.RLock()
if cachedAICfg != nil {
ptr := cachedAICfg
mu.RUnlock()
return ptr, nil
}
mu.RUnlock()
}
mu.Lock()
defer mu.Unlock()
if !fr && cachedAICfg != nil {
return cachedAICfg, nil
}
var cfg AIConfig
if err := loadJSON("ai_config.json", &cfg); err != nil {
return nil, err
}
cachedAICfg = &cfg
return &cfg, nil
}
func LoadAISecrets(forceReload ...bool) (*AISecrets, error) {
fr := len(forceReload) > 0 && forceReload[0]
if !fr {
mu.RLock()
if cachedSecrets != nil {
ptr := cachedSecrets
mu.RUnlock()
return ptr, nil
}
mu.RUnlock()
}
mu.Lock()
defer mu.Unlock()
if !fr && cachedSecrets != nil {
return cachedSecrets, nil
}
var s AISecrets
if err := loadJSON("ai_secrets.json", &s); err != nil {
// 文件不存在时不报错,返回空结构
return &AISecrets{}, nil
}
cachedSecrets = &s
return &s, nil
}
func LoadPlatformConfig(forceReload ...bool) (*PlatformConfig, error) {
fr := len(forceReload) > 0 && forceReload[0]
if !fr {
mu.RLock()
if cachedPlatform != nil {
ptr := cachedPlatform
mu.RUnlock()
return ptr, nil
}
mu.RUnlock()
}
mu.Lock()
defer mu.Unlock()
if !fr && cachedPlatform != nil {
return cachedPlatform, nil
}
var p PlatformConfig
if err := loadJSON("platform.json", &p); err != nil {
return nil, err
}
cachedPlatform = &p
return &p, nil
}
// ──────────────────────────────────────────────
// GetRoute — 核心路由解析(参考 pj034 get_route())
// 输入 agent 名或 route_id,返回完整 RouteConfig(已注入 base_url / api_key)
// 查找路径:agent_routes → 分类 routes(chats/embeds/images) → 兼容旧版 routes → default
// ──────────────────────────────────────────────
func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, fmt.Errorf("AI 配置加载失败: %w", err)
}
// 1) 先当 agent 名查
routeID := agentOrRouteID
if r, ok := aiCfg.AgentRoutes[agentOrRouteID]; ok {
routeID = r
}
// 2) 在分类路由中查找
info, category, found := findRoute(aiCfg, routeID)
if !found {
// 3) 回退默认
if aiCfg.DefaultRoute != "" {
routeID = aiCfg.DefaultRoute
info, category, found = findRoute(aiCfg, routeID)
}
if !found {
return nil, fmt.Errorf("路由 %q 未在 ai_config.json 中定义,且无有效默认路由", agentOrRouteID)
}
}
// 4) 从 secrets 注入 base_url / api_key
baseURL := ""
apiKey := ""
secrets, _ := LoadAISecrets()
// 先查 provider 对应的 secrets key
if secretField, ok := ProviderSecretKey[info.Provider]; ok && secrets != nil {
// 通过反射取 struct 字段值
apiKey = getSecretByField(secrets, secretField)
}
// base_url: 先从 ProviderDefaultBaseURL 取
if url, ok := ProviderDefaultBaseURL[info.Provider]; ok {
baseURL = url
}
// 组装
fullURL := strings.TrimRight(baseURL, "/") + info.Endpoint
rc := &RouteConfig{
RouteID: routeID,
Provider: info.Provider,
Model: info.Model,
BaseURL: baseURL,
Endpoint: info.Endpoint,
FullURL: fullURL,
APIKey: apiKey,
MaxTokens: info.MaxTokens,
Temperature: info.Temperature,
Category: category,
Description: info.Description,
}
if rc.MaxTokens <= 0 {
rc.MaxTokens = 2048
}
if rc.Temperature <= 0 {
rc.Temperature = 0.7
}
return rc, nil
}
// findRoute 在 chat_routes / embed_routes / image_routes / 兼容 routes 中查找
func findRoute(cfg *AIConfig, routeID string) (RouteInfo, string, bool) {
if cfg.ChatRoutes != nil {
if info, ok := cfg.ChatRoutes[routeID]; ok {
return info, "chat", true
}
}
if cfg.EmbedRoutes != nil {
if info, ok := cfg.EmbedRoutes[routeID]; ok {
return info, "embed", true
}
}
if cfg.ImageRoutes != nil {
if info, ok := cfg.ImageRoutes[routeID]; ok {
return info, "image", true
}
}
// 兼容旧版平铺 routes
if cfg.Routes != nil {
if info, ok := cfg.Routes[routeID]; ok {
return info, info.Category, true
}
}
return RouteInfo{}, "", false
}
// getSecretByField 从 AISecrets 按字段名取值
func getSecretByField(s *AISecrets, field string) string {
switch field {
case "VECTORENGINE_API_KEY":
return s.VECTORENGINE_API_KEY
case "OPENROUTER_API_KEY":
return s.OPENROUTER_API_KEY
case "VOLCES_API_KEY":
return s.VOLCES_API_KEY
case "LMUAI_API_KEY":
return s.LMUAI_API_KEY
case "ALIYUN_API_KEY":
return s.ALIYUN_API_KEY
case "DEEPSEEK_API_KEY":
return s.DEEPSEEK_API_KEY
case "REMOVE_BG_API_KEY":
return s.REMOVE_BG_API_KEY
case "ANTHROPIC_API_KEY":
return s.ANTHROPIC_API_KEY
case "OPENAI_API_KEY":
return s.OPENAI_API_KEY
case "BRAVE_SEARCH_API_KEY":
return s.BRAVE_SEARCH_API_KEY
default:
return ""
}
}
// ──────────────────────────────────────────────
// 辅助函数
// ──────────────────────────────────────────────
// GetFallbackRoutes 获取回退路由链
func GetFallbackRoutes(primaryRouteID string) ([]*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, err
}
fallbackIDs := aiCfg.FallbackRoutes[primaryRouteID]
if len(fallbackIDs) == 0 {
return nil, nil
}
var result []*RouteConfig
for _, fid := range fallbackIDs {
r, err := GetRoute(fid)
if err != nil {
continue
}
result = append(result, r)
}
return result, nil
}
// GetAllRoutes 返回所有定义的路由(跨分类)
func GetAllRoutes() ([]*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, err
}
var result []*RouteConfig
seen := make(map[string]bool)
addRoutes := func(routes map[string]RouteInfo, category string) {
for rid := range routes {
if seen[rid] {
continue
}
seen[rid] = true
r, err := GetRoute(rid)
if err != nil {
continue
}
r.Category = category
result = append(result, r)
}
}
addRoutes(aiCfg.ChatRoutes, "chat")
addRoutes(aiCfg.EmbedRoutes, "embed")
addRoutes(aiCfg.ImageRoutes, "image")
// 兼容旧版
if aiCfg.Routes != nil {
for rid, info := range aiCfg.Routes {
if seen[rid] {
continue
}
seen[rid] = true
cat := info.Category
if cat == "" {
cat = "general"
}
r, err := GetRoute(rid)
if err != nil {
continue
}
r.Category = cat
result = append(result, r)
}
}
return result, nil
}
// GetRoutesByCategory 按分类返回路由
func GetRoutesByCategory(category string) ([]*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, err
}
var routeMap map[string]RouteInfo
switch category {
case "chat":
routeMap = aiCfg.ChatRoutes
case "embed":
routeMap = aiCfg.EmbedRoutes
case "image":
routeMap = aiCfg.ImageRoutes
default:
return nil, fmt.Errorf("未知路由分类: %s", category)
}
var result []*RouteConfig
for rid := range routeMap {
r, err := GetRoute(rid)
if err != nil {
continue
}
result = append(result, r)
}
return result, nil
}
// GetProviderAPIKey 直接获取指定 provider 的 API key
func GetProviderAPIKey(provider string) string {
secrets, _ := LoadAISecrets()
if secrets == nil {
return ""
}
if field, ok := ProviderSecretKey[provider]; ok {
return getSecretByField(secrets, field)
}
return ""
}
// GetProviderBaseURL 获取指定 provider 的默认 base_url
func GetProviderBaseURL(provider string) string {
if url, ok := ProviderDefaultBaseURL[provider]; ok {
return url
}
return ""
}
// ──────────────────────────────────────────────
// AI 配置管理(管理员:读/写/热重载/密钥状态)
// ──────────────────────────────────────────────
// GetAIConfigRaw 读取 ai_config.json 原始内容(供前端 JSON 编辑器)
func GetAIConfigRaw() ([]byte, error) {
return os.ReadFile(resolveConfigPath("ai_config.json"))
}
// SaveAIConfig 校验并原子写回 ai_config.json,随后清缓存热生效
func SaveAIConfig(raw []byte) error {
// 先校验合法性(能反序列化为 AIConfig 且有 version)
var cfg AIConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("ai_config.json 不是合法 JSON: %w", err)
}
if cfg.Version == "" {
return fmt.Errorf("ai_config.json 缺少 version 字段")
}
path := resolveConfigPath("ai_config.json")
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return fmt.Errorf("写入临时文件失败: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("替换配置文件失败: %w", err)
}
mu.Lock()
cachedAICfg = nil
mu.Unlock()
return nil
}
// ResetCache 清空全部配置缓存(热重载,无需重启)
func ResetCache() {
mu.Lock()
cachedAICfg = nil
cachedSecrets = nil
cachedPlatform = nil
mu.Unlock()
}
// SecretsStatus 返回各 provider 密钥是否已配置(不回显明文)
func SecretsStatus() map[string]bool {
secrets, _ := LoadAISecrets()
status := make(map[string]bool)
if secrets == nil {
return status
}
for provider, field := range ProviderSecretKey {
status[provider] = strings.TrimSpace(getSecretByField(secrets, field)) != ""
}
return status
}
@@ -0,0 +1,645 @@
package connector
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"time"
"eaisalestrain/backend/internal/config"
)
type KingdeeConnector struct {
cfg *config.Config
}
func NewKingdee(cfg *config.Config) *KingdeeConnector {
return &KingdeeConnector{cfg: cfg}
}
func (k *KingdeeConnector) Definition() Definition {
mode := "demo"
status := "not_configured"
if k.isConfigured() {
mode = "live"
status = "ready"
}
objects := make([]ObjectDefinition, 0, len(kingdeeObjectCatalog))
for _, item := range kingdeeObjectOrder {
obj := kingdeeObjectCatalog[item]
objects = append(objects, obj)
}
return Definition{
Key: "kingdee",
Label: "金蝶云星空读取连接器",
Vendor: "Kingdee",
Category: "erp",
Direction: "input",
Description: "面向 DW / ADW 的金蝶只读连接器,优先支持 ExecuteBillQuery 拉取主数据与业务单据。",
Mode: mode,
Status: status,
AuthConfigured: k.isConfigured(),
Capabilities: []string{"read", "query", "schema_hint"},
Actions: []ActionDefinition{
{Key: "fetch_customer", Label: "读取客户", ActionType: "FetchCustomer", Description: "从金蝶读取客户主数据"},
{Key: "fetch_material", Label: "读取物料", ActionType: "FetchMaterial", Description: "从金蝶读取物料主数据"},
{Key: "fetch_sales_order", Label: "读取销售订单", ActionType: "FetchSalesOrder", Description: "从金蝶读取销售订单"},
{Key: "fetch_purchase_order", Label: "读取采购订单", ActionType: "FetchPurchaseOrder", Description: "从金蝶读取采购订单"},
{Key: "query_business_document", Label: "查询业务单据", ActionType: "QueryBusinessDocument", Description: "按表单和字段查询金蝶业务对象"},
},
Objects: objects,
}
}
func (k *KingdeeConnector) Query(ctx context.Context, req QueryRequest) (QueryResult, error) {
objectDef, err := k.resolveObject(req.ObjectKey, req.FormID)
if err != nil {
return QueryResult{}, err
}
req.ObjectKey = objectDef.Key
req.FormID = firstNonEmpty(req.FormID, objectDef.RecommendedFormID)
req.FieldKeys = normalizeFieldKeys(req.FieldKeys, objectDef.DefaultFields)
req.Limit = normalizeLimit(req.Limit)
if req.StartRow < 0 {
req.StartRow = 0
}
if req.UseDemo || !k.isConfigured() {
return k.queryDemo(objectDef, req), nil
}
return k.queryLive(ctx, objectDef, req)
}
func (k *KingdeeConnector) queryDemo(objectDef ObjectDefinition, req QueryRequest) QueryResult {
sourceRows := cloneRows(kingdeeDemoData[objectDef.Key])
records := sliceRows(sourceRows, req.StartRow, req.Limit)
if len(req.FieldKeys) > 0 {
records = selectFields(records, req.FieldKeys)
}
risks := []map[string]any{
{
"level": "low",
"code": "demo_mode",
"message": "当前使用内置演示数据,未直连真实金蝶环境。",
},
}
if strings.TrimSpace(req.FilterString) != "" {
risks = append(risks, map[string]any{
"level": "low",
"code": "filter_not_applied",
"message": "演示模式未执行 FilterString,请以 live 模式验证过滤条件。",
})
}
return QueryResult{
ConnectorKey: "kingdee",
ConnectorLabel: "金蝶云星空读取连接器",
ObjectKey: objectDef.Key,
ObjectLabel: objectDef.Label,
Mode: "demo",
Request: buildRequestEcho(req),
Outputs: map[string]any{
"form_id": req.FormID,
"field_keys": req.FieldKeys,
"total": len(sourceRows),
"count": len(records),
"records": records,
},
Artifacts: []map[string]any{},
Citations: []map[string]any{
{
"title": fmt.Sprintf("金蝶演示对象 %s", objectDef.Label),
"source": "builtin_demo_dataset",
"connector": "kingdee",
"fetched_at": time.Now().Format(time.RFC3339),
},
},
Risks: risks,
}
}
func (k *KingdeeConnector) queryLive(ctx context.Context, objectDef ObjectDefinition, req QueryRequest) (QueryResult, error) {
client, err := k.newHTTPClient()
if err != nil {
return QueryResult{}, err
}
if err := k.login(ctx, client); err != nil {
return QueryResult{}, err
}
records, err := k.executeBillQuery(ctx, client, req)
if err != nil {
return QueryResult{}, err
}
return QueryResult{
ConnectorKey: "kingdee",
ConnectorLabel: "金蝶云星空读取连接器",
ObjectKey: objectDef.Key,
ObjectLabel: objectDef.Label,
Mode: "live",
Request: buildRequestEcho(req),
Outputs: map[string]any{
"form_id": req.FormID,
"field_keys": req.FieldKeys,
"count": len(records),
"records": records,
},
Artifacts: []map[string]any{},
Citations: []map[string]any{
{
"title": fmt.Sprintf("金蝶实时对象 %s", objectDef.Label),
"source": sanitizeKingdeeBaseURL(k.cfg.KingdeeBaseURL),
"connector": "kingdee",
"fetched_at": time.Now().Format(time.RFC3339),
},
},
Risks: []map[string]any{},
}, nil
}
func (k *KingdeeConnector) newHTTPClient() (*http.Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
timeoutSec := k.cfg.KingdeeTimeoutSec
if timeoutSec <= 0 {
timeoutSec = 15
}
return &http.Client{
Timeout: time.Duration(timeoutSec) * time.Second,
Jar: jar,
}, nil
}
func (k *KingdeeConnector) login(ctx context.Context, client *http.Client) error {
loginURL := sanitizeKingdeeBaseURL(k.cfg.KingdeeBaseURL) + "Kingdee.BOS.WebApi.ServicesStub.AuthService.ValidateUser.common.kdsvc"
payloads := []any{
map[string]any{
"acctid": k.cfg.KingdeeAccountID,
"username": k.cfg.KingdeeUsername,
"password": k.cfg.KingdeePassword,
"lcid": k.cfg.KingdeeLCID,
},
map[string]any{
"acctID": k.cfg.KingdeeAccountID,
"username": k.cfg.KingdeeUsername,
"password": k.cfg.KingdeePassword,
"lcid": k.cfg.KingdeeLCID,
},
[]any{k.cfg.KingdeeAccountID, k.cfg.KingdeeUsername, k.cfg.KingdeePassword, k.cfg.KingdeeLCID},
}
var lastErr error
for _, payload := range payloads {
raw, err := postJSON(ctx, client, loginURL, payload)
if err != nil {
lastErr = err
continue
}
if loginOK(raw) {
return nil
}
lastErr = fmt.Errorf("kingdee login failed: %s", summarizeRemoteMessage(raw))
}
if lastErr == nil {
lastErr = errors.New("kingdee login failed")
}
return lastErr
}
func (k *KingdeeConnector) executeBillQuery(ctx context.Context, client *http.Client, req QueryRequest) ([]map[string]any, error) {
queryURL := sanitizeKingdeeBaseURL(k.cfg.KingdeeBaseURL) + "Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.ExecuteBillQuery.common.kdsvc"
payload := map[string]any{
"FormId": req.FormID,
"FieldKeys": strings.Join(req.FieldKeys, ","),
"FilterString": strings.TrimSpace(req.FilterString),
"OrderString": strings.TrimSpace(req.OrderString),
"TopRowCount": 0,
"StartRow": req.StartRow,
"Limit": req.Limit,
"SubSystemId": "",
}
raw, err := postJSON(ctx, client, queryURL, payload)
if err != nil {
return nil, err
}
if msg := extractRemoteError(raw); msg != "" {
return nil, errors.New(msg)
}
var parsed any
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("parse kingdee query response: %w", err)
}
rows := normalizeKingdeeRows(parsed, req.FieldKeys)
return rows, nil
}
func (k *KingdeeConnector) resolveObject(objectKey string, formID string) (ObjectDefinition, error) {
objectKey = strings.TrimSpace(objectKey)
if objectKey != "" {
if objectDef, ok := kingdeeObjectCatalog[objectKey]; ok {
return objectDef, nil
}
return ObjectDefinition{}, fmt.Errorf("unsupported kingdee object: %s", objectKey)
}
if strings.TrimSpace(formID) == "" {
return ObjectDefinition{}, errors.New("object_key 或 form_id 至少填写一个")
}
return ObjectDefinition{
Key: "custom_query",
Label: "自定义表单查询",
Description: "通过自定义 FormId 执行 ExecuteBillQuery",
Mode: "read",
RecommendedFormID: strings.TrimSpace(formID),
DefaultFields: []string{},
FilterHint: "例如:FDocumentStatus = 'C'",
}, nil
}
func (k *KingdeeConnector) isConfigured() bool {
return strings.TrimSpace(k.cfg.KingdeeBaseURL) != "" &&
strings.TrimSpace(k.cfg.KingdeeAccountID) != "" &&
strings.TrimSpace(k.cfg.KingdeeUsername) != "" &&
strings.TrimSpace(k.cfg.KingdeePassword) != ""
}
func postJSON(ctx context.Context, client *http.Client, url string, payload any) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return data, nil
}
func loginOK(raw []byte) bool {
var parsed any
if err := json.Unmarshal(raw, &parsed); err != nil {
return false
}
switch node := parsed.(type) {
case map[string]any:
if value, ok := node["LoginResultType"].(float64); ok && int(value) == 1 {
return true
}
if result, ok := node["Result"].(map[string]any); ok {
if value, ok := result["LoginResultType"].(float64); ok && int(value) == 1 {
return true
}
if responseStatus, ok := result["ResponseStatus"].(map[string]any); ok {
if success, ok := responseStatus["IsSuccess"].(bool); ok && success {
return true
}
}
}
}
text := strings.ToLower(string(raw))
return strings.Contains(text, `"issuccess":true`) ||
strings.Contains(text, `"issuccessbyapi":true`) ||
strings.Contains(text, `"loginresulttype":1`)
}
func extractRemoteError(raw []byte) string {
var parsed any
if err := json.Unmarshal(raw, &parsed); err != nil {
return ""
}
return extractErrorFromParsed(parsed)
}
func extractErrorFromParsed(parsed any) string {
switch node := parsed.(type) {
case map[string]any:
if result, ok := node["Result"].(map[string]any); ok {
if responseStatus, ok := result["ResponseStatus"].(map[string]any); ok {
if success, ok := responseStatus["IsSuccess"].(bool); ok && !success {
if errorsList, ok := responseStatus["Errors"].([]any); ok && len(errorsList) > 0 {
messages := make([]string, 0, len(errorsList))
for _, item := range errorsList {
if msg, ok := item.(map[string]any)["Message"].(string); ok && strings.TrimSpace(msg) != "" {
messages = append(messages, strings.TrimSpace(msg))
}
}
if len(messages) > 0 {
return strings.Join(messages, "; ")
}
}
return "kingdee response reports failure"
}
}
}
if msg, ok := node["Message"].(string); ok && strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg)
}
}
text := strings.TrimSpace(stringMustJSON(parsed))
if strings.Contains(text, "会话信息已丢失") {
return "会话信息已丢失,请重新登录"
}
return ""
}
func summarizeRemoteMessage(raw []byte) string {
if msg := extractRemoteError(raw); msg != "" {
return msg
}
return strings.TrimSpace(string(raw))
}
func normalizeKingdeeRows(parsed any, fieldKeys []string) []map[string]any {
source := parsed
if root, ok := parsed.(map[string]any); ok {
if result, exists := root["Result"]; exists {
source = result
}
}
switch node := source.(type) {
case []any:
rows := make([]map[string]any, 0, len(node))
for idx, item := range node {
switch row := item.(type) {
case []any:
if idx == 0 && isHeaderRow(row, fieldKeys) {
continue
}
rows = append(rows, zipRow(fieldKeys, row))
case map[string]any:
rows = append(rows, row)
}
}
return rows
case map[string]any:
return []map[string]any{node}
default:
return []map[string]any{}
}
}
func isHeaderRow(row []any, fieldKeys []string) bool {
if len(row) != len(fieldKeys) || len(fieldKeys) == 0 {
return false
}
for i := range row {
value, ok := row[i].(string)
if !ok || strings.TrimSpace(value) != fieldKeys[i] {
return false
}
}
return true
}
func zipRow(fieldKeys []string, values []any) map[string]any {
row := make(map[string]any, len(values))
for idx, value := range values {
key := fmt.Sprintf("col_%d", idx+1)
if idx < len(fieldKeys) && strings.TrimSpace(fieldKeys[idx]) != "" {
key = fieldKeys[idx]
}
row[key] = value
}
return row
}
func normalizeFieldKeys(fieldKeys []string, defaults []string) []string {
normalized := make([]string, 0, len(fieldKeys))
for _, item := range fieldKeys {
item = strings.TrimSpace(item)
if item != "" {
normalized = append(normalized, item)
}
}
if len(normalized) == 0 {
return append([]string{}, defaults...)
}
return normalized
}
func normalizeLimit(limit int) int {
switch {
case limit <= 0:
return 20
case limit > 2000:
return 2000
default:
return limit
}
}
func buildRequestEcho(req QueryRequest) map[string]any {
return map[string]any{
"object_key": req.ObjectKey,
"form_id": req.FormID,
"field_keys": req.FieldKeys,
"filter_string": req.FilterString,
"order_string": req.OrderString,
"start_row": req.StartRow,
"limit": req.Limit,
"use_demo": req.UseDemo,
}
}
func sanitizeKingdeeBaseURL(base string) string {
base = strings.TrimSpace(base)
if base == "" {
return ""
}
base = strings.TrimRight(base, "/")
if !strings.Contains(strings.ToLower(base), "/k3cloud") {
base += "/K3Cloud"
}
return strings.TrimRight(base, "/") + "/"
}
func cloneRows(rows []map[string]any) []map[string]any {
cloned := make([]map[string]any, 0, len(rows))
for _, row := range rows {
copyRow := make(map[string]any, len(row))
for key, value := range row {
copyRow[key] = value
}
cloned = append(cloned, copyRow)
}
return cloned
}
func sliceRows(rows []map[string]any, startRow int, limit int) []map[string]any {
if startRow >= len(rows) {
return []map[string]any{}
}
end := startRow + limit
if end > len(rows) {
end = len(rows)
}
return rows[startRow:end]
}
func selectFields(rows []map[string]any, fieldKeys []string) []map[string]any {
selected := make([]map[string]any, 0, len(rows))
for _, row := range rows {
item := make(map[string]any, len(fieldKeys))
for _, fieldKey := range fieldKeys {
item[fieldKey] = row[fieldKey]
}
selected = append(selected, item)
}
return selected
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func stringMustJSON(v any) string {
data, err := json.Marshal(v)
if err != nil {
return ""
}
return string(data)
}
var kingdeeObjectOrder = []string{
"customer",
"supplier",
"material",
"sales_order",
"purchase_order",
"inventory",
"receivable",
}
var kingdeeObjectCatalog = map[string]ObjectDefinition{
"customer": {
Key: "customer",
Label: "客户主数据",
Description: "读取金蝶客户基础资料",
Mode: "read",
RecommendedFormID: "BD_Customer",
DefaultFields: []string{"FNumber", "FName", "FUseOrgId.FNumber", "FCreateOrgId.FNumber", "FDocumentStatus"},
FilterHint: "例如:FDocumentStatus = 'C'",
},
"supplier": {
Key: "supplier",
Label: "供应商主数据",
Description: "读取金蝶供应商基础资料",
Mode: "read",
RecommendedFormID: "BD_Supplier",
DefaultFields: []string{"FNumber", "FName", "FUseOrgId.FNumber", "FCreateOrgId.FNumber", "FDocumentStatus"},
FilterHint: "例如:FForbidStatus = 'A'",
},
"material": {
Key: "material",
Label: "物料主数据",
Description: "读取金蝶物料基础资料",
Mode: "read",
RecommendedFormID: "BD_MATERIAL",
DefaultFields: []string{"FNumber", "FName", "FSpecification", "FBaseUnitId.FNumber", "FDocumentStatus"},
FilterHint: "例如:FDocumentStatus = 'C'",
},
"sales_order": {
Key: "sales_order",
Label: "销售订单",
Description: "读取金蝶销售订单头信息",
Mode: "read",
RecommendedFormID: "SAL_SaleOrder",
DefaultFields: []string{"FBillNo", "FDate", "FCustId.FNumber", "FSaleOrgId.FNumber", "FDocumentStatus"},
FilterHint: "例如:FApproveDate >= '2026-08-01'",
},
"purchase_order": {
Key: "purchase_order",
Label: "采购订单",
Description: "读取金蝶采购订单头信息",
Mode: "read",
RecommendedFormID: "PUR_PurchaseOrder",
DefaultFields: []string{"FBillNo", "FDate", "FSupplierId.FNumber", "FPurchaseOrgId.FNumber", "FDocumentStatus"},
FilterHint: "例如:FDocumentStatus = 'C'",
},
"inventory": {
Key: "inventory",
Label: "即时库存",
Description: "读取金蝶库存对象",
Mode: "read",
RecommendedFormID: "STK_Inventory",
DefaultFields: []string{"FMaterialId.FNumber", "FBaseQty", "FStockId.FNumber", "FOwnerId.FNumber"},
FilterHint: "例如:FBaseQty > 0",
},
"receivable": {
Key: "receivable",
Label: "应收单",
Description: "读取金蝶应收单对象",
Mode: "read",
RecommendedFormID: "AR_receivable",
DefaultFields: []string{"FBillNo", "FDate", "FCUSTOMERID.FNumber", "FSALEORGID.FNumber", "FDOCUMENTSTATUS"},
FilterHint: "例如:FDOCUMENTSTATUS = 'C'",
},
}
var kingdeeDemoData = map[string][]map[string]any{
"customer": {
{"FNumber": "CUST0001", "FName": "华北渠道中心", "FUseOrgId.FNumber": "100", "FCreateOrgId.FNumber": "100", "FDocumentStatus": "C"},
{"FNumber": "CUST0002", "FName": "博昇样板客户", "FUseOrgId.FNumber": "100", "FCreateOrgId.FNumber": "100", "FDocumentStatus": "C"},
},
"supplier": {
{"FNumber": "SUP0001", "FName": "精密零部件供应商", "FUseOrgId.FNumber": "100", "FCreateOrgId.FNumber": "100", "FDocumentStatus": "C"},
{"FNumber": "SUP0002", "FName": "包装材料供应商", "FUseOrgId.FNumber": "100", "FCreateOrgId.FNumber": "100", "FDocumentStatus": "C"},
},
"material": {
{"FNumber": "MAT-1001", "FName": "高强度连接件", "FSpecification": "M8", "FBaseUnitId.FNumber": "PCS", "FDocumentStatus": "C"},
{"FNumber": "MAT-1002", "FName": "控制模块", "FSpecification": "CTRL-A", "FBaseUnitId.FNumber": "SET", "FDocumentStatus": "C"},
},
"sales_order": {
{"FBillNo": "SO20260816001", "FDate": "2026-08-16", "FCustId.FNumber": "CUST0001", "FSaleOrgId.FNumber": "100", "FDocumentStatus": "C"},
{"FBillNo": "SO20260816002", "FDate": "2026-08-16", "FCustId.FNumber": "CUST0002", "FSaleOrgId.FNumber": "100", "FDocumentStatus": "A"},
},
"purchase_order": {
{"FBillNo": "PO20260816001", "FDate": "2026-08-15", "FSupplierId.FNumber": "SUP0001", "FPurchaseOrgId.FNumber": "100", "FDocumentStatus": "C"},
{"FBillNo": "PO20260816002", "FDate": "2026-08-16", "FSupplierId.FNumber": "SUP0002", "FPurchaseOrgId.FNumber": "100", "FDocumentStatus": "B"},
},
"inventory": {
{"FMaterialId.FNumber": "MAT-1001", "FBaseQty": 1820, "FStockId.FNumber": "RAW-01", "FOwnerId.FNumber": "100"},
{"FMaterialId.FNumber": "MAT-1002", "FBaseQty": 96, "FStockId.FNumber": "FG-01", "FOwnerId.FNumber": "100"},
},
"receivable": {
{"FBillNo": "AR20260816001", "FDate": "2026-08-12", "FCUSTOMERID.FNumber": "CUST0001", "FSALEORGID.FNumber": "100", "FDOCUMENTSTATUS": "C"},
{"FBillNo": "AR20260816002", "FDate": "2026-08-13", "FCUSTOMERID.FNumber": "CUST0002", "FSALEORGID.FNumber": "100", "FDOCUMENTSTATUS": "A"},
},
}
@@ -0,0 +1,93 @@
package connector
import (
"context"
"testing"
"eaisalestrain/backend/internal/config"
)
func TestKingdeeDefinitionDefaultsToDemo(t *testing.T) {
definition := NewKingdee(&config.Config{}).Definition()
if definition.Key != "kingdee" {
t.Fatalf("unexpected key: %s", definition.Key)
}
if definition.Direction != "input" {
t.Fatalf("expected input direction, got %s", definition.Direction)
}
if definition.Mode != "demo" {
t.Fatalf("expected demo mode, got %s", definition.Mode)
}
if len(definition.Objects) == 0 {
t.Fatal("expected predefined objects")
}
}
func TestKingdeeQueryDemoSelectsFields(t *testing.T) {
result, err := NewKingdee(&config.Config{}).Query(context.Background(), QueryRequest{
ObjectKey: "sales_order",
FieldKeys: []string{"FBillNo", "FCustId.FNumber"},
Limit: 1,
})
if err != nil {
t.Fatalf("query failed: %v", err)
}
if result.Mode != "demo" {
t.Fatalf("expected demo mode, got %s", result.Mode)
}
records, ok := result.Outputs["records"].([]map[string]any)
if !ok {
t.Fatalf("records type mismatch: %T", result.Outputs["records"])
}
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if _, exists := records[0]["FBillNo"]; !exists {
t.Fatal("expected FBillNo field")
}
if _, exists := records[0]["FCustId.FNumber"]; !exists {
t.Fatal("expected FCustId.FNumber field")
}
if _, exists := records[0]["FDate"]; exists {
t.Fatal("did not expect FDate field in selected output")
}
}
func TestListDefinitionsContainsInputAndOutputConnectors(t *testing.T) {
definitions := ListDefinitions(&config.Config{})
if len(definitions) < 8 {
t.Fatalf("expected multiple connectors, got %d", len(definitions))
}
foundInput := false
foundOutput := false
for _, item := range definitions {
if item.Direction == "input" {
foundInput = true
}
if item.Direction == "output" {
foundOutput = true
}
}
if !foundInput || !foundOutput {
t.Fatalf("expected both input and output connectors, input=%v output=%v", foundInput, foundOutput)
}
}
func TestStaticInputConnectorQueryDemo(t *testing.T) {
result, err := Query(context.Background(), &config.Config{}, "wecom_sheet_input", QueryRequest{
ObjectKey: "sheet_record",
FieldKeys: []string{"record_id", "title"},
Limit: 1,
})
if err != nil {
t.Fatalf("query failed: %v", err)
}
if result.ConnectorKey != "wecom_sheet_input" {
t.Fatalf("unexpected connector key: %s", result.ConnectorKey)
}
if result.ObjectKey != "sheet_record" {
t.Fatalf("unexpected object key: %s", result.ObjectKey)
}
}
@@ -0,0 +1,373 @@
package connector
import (
"errors"
"fmt"
"strings"
"time"
"eaisalestrain/backend/internal/config"
)
var ErrConnectorNotFound = errors.New("connector not found")
var ErrConnectorQueryUnsupported = errors.New("connector query unsupported")
var connectorOrder = []string{
"kingdee",
"wms_input",
"mes_input",
"mail163_input",
"dingtalk_table_input",
"feishu_bitable_input",
"wecom_sheet_input",
"dingtalk_table_output",
"feishu_bitable_output",
"wecom_sheet_output",
}
func registry(cfg *config.Config) map[string]Definition {
items := map[string]Definition{
"kingdee": NewKingdee(cfg).Definition(),
"wms_input": staticDefinitions["wms_input"],
"mes_input": staticDefinitions["mes_input"],
"mail163_input": staticDefinitions["mail163_input"],
"dingtalk_table_input": staticDefinitions["dingtalk_table_input"],
"feishu_bitable_input": staticDefinitions["feishu_bitable_input"],
"wecom_sheet_input": staticDefinitions["wecom_sheet_input"],
"dingtalk_table_output": staticDefinitions["dingtalk_table_output"],
"feishu_bitable_output": staticDefinitions["feishu_bitable_output"],
"wecom_sheet_output": staticDefinitions["wecom_sheet_output"],
}
return items
}
func queryStaticConnector(cfg *config.Config, key string, req QueryRequest) (QueryResult, error) {
definition, ok := registry(cfg)[key]
if !ok {
return QueryResult{}, ErrConnectorNotFound
}
if definition.Direction != "input" {
return QueryResult{}, ErrConnectorQueryUnsupported
}
objectDef, err := resolveStaticObject(definition, req.ObjectKey)
if err != nil {
return QueryResult{}, err
}
req.ObjectKey = objectDef.Key
req.FieldKeys = normalizeFieldKeys(req.FieldKeys, objectDef.DefaultFields)
req.Limit = normalizeLimit(req.Limit)
if req.StartRow < 0 {
req.StartRow = 0
}
sourceRows := cloneRows(staticDemoRows[key][objectDef.Key])
records := sliceRows(sourceRows, req.StartRow, req.Limit)
if len(req.FieldKeys) > 0 {
records = selectFields(records, req.FieldKeys)
}
return QueryResult{
ConnectorKey: definition.Key,
ConnectorLabel: definition.Label,
ObjectKey: objectDef.Key,
ObjectLabel: objectDef.Label,
Mode: definition.Mode,
Request: buildRequestEcho(req),
Outputs: map[string]any{
"count": len(records),
"total": len(sourceRows),
"records": records,
},
Artifacts: []map[string]any{},
Citations: []map[string]any{
{
"title": fmt.Sprintf("%s 演示对象 %s", definition.Label, objectDef.Label),
"source": "builtin_demo_dataset",
"connector": definition.Key,
"fetched_at": time.Now().Format(time.RFC3339),
},
},
Risks: []map[string]any{
{
"level": "low",
"code": "demo_mode",
"message": "当前连接器为演示模式,尚未绑定真实系统实例。",
},
},
}, nil
}
func resolveStaticObject(definition Definition, objectKey string) (ObjectDefinition, error) {
objectKey = strings.TrimSpace(objectKey)
if objectKey == "" {
if len(definition.Objects) == 0 {
return ObjectDefinition{}, errors.New("当前连接器未定义对象")
}
return definition.Objects[0], nil
}
for _, item := range definition.Objects {
if item.Key == objectKey {
return item, nil
}
}
return ObjectDefinition{}, fmt.Errorf("unsupported object for connector %s: %s", definition.Key, objectKey)
}
var staticDefinitions = map[string]Definition{
"wms_input": {
Key: "wms_input",
Label: "WMS 输入连接器",
Vendor: "Generic",
Category: "wms",
Direction: "input",
Description: "面向仓储业务读取库存、入库、出库等仓储执行数据。",
Mode: "demo",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"read", "query", "inventory"},
Actions: []ActionDefinition{
{Key: "fetch_inventory_snapshot", Label: "读取库存快照", ActionType: "FetchInventorySnapshot", Description: "读取仓位库存与可用库存"},
{Key: "fetch_inbound_notice", Label: "读取入库通知", ActionType: "FetchInboundNotice", Description: "读取待入库单据"},
{Key: "fetch_outbound_task", Label: "读取出库任务", ActionType: "FetchOutboundTask", Description: "读取待执行出库任务"},
},
Objects: []ObjectDefinition{
{Key: "inventory_snapshot", Label: "库存快照", Description: "按仓位读取库存记录", Mode: "read", RecommendedFormID: "inventory_snapshot", DefaultFields: []string{"warehouse_code", "location_code", "sku", "on_hand_qty", "available_qty"}, FilterHint: "例如:available_qty > 0"},
{Key: "inbound_notice", Label: "入库通知", Description: "读取待收货单据", Mode: "read", RecommendedFormID: "inbound_notice", DefaultFields: []string{"notice_no", "supplier_name", "warehouse_code", "eta_date", "status"}, FilterHint: "例如:status = 'pending'"},
{Key: "outbound_task", Label: "出库任务", Description: "读取拣货与发运任务", Mode: "read", RecommendedFormID: "outbound_task", DefaultFields: []string{"task_no", "order_no", "warehouse_code", "pick_status", "carrier"}, FilterHint: "例如:pick_status != 'done'"},
},
},
"mes_input": {
Key: "mes_input",
Label: "MES 输入连接器",
Vendor: "Generic",
Category: "mes",
Direction: "input",
Description: "面向制造执行读取工单、设备告警、报工与质量数据。",
Mode: "demo",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"read", "query", "production"},
Actions: []ActionDefinition{
{Key: "fetch_work_order", Label: "读取工单", ActionType: "FetchWorkOrder", Description: "读取生产工单及执行状态"},
{Key: "fetch_equipment_alarm", Label: "读取设备告警", ActionType: "FetchEquipmentAlarm", Description: "读取产线设备异常告警"},
{Key: "fetch_production_report", Label: "读取生产报工", ActionType: "FetchProductionReport", Description: "读取报工和产量数据"},
},
Objects: []ObjectDefinition{
{Key: "work_order", Label: "生产工单", Description: "读取工单状态和排产信息", Mode: "read", RecommendedFormID: "work_order", DefaultFields: []string{"work_order_no", "product_code", "line_code", "planned_qty", "status"}, FilterHint: "例如:status in ('released','running')"},
{Key: "equipment_alarm", Label: "设备告警", Description: "读取产线设备告警信息", Mode: "read", RecommendedFormID: "equipment_alarm", DefaultFields: []string{"alarm_no", "equipment_code", "alarm_level", "alarm_time", "status"}, FilterHint: "例如:status = 'open'"},
{Key: "production_report", Label: "生产报工", Description: "读取班次报工与产量统计", Mode: "read", RecommendedFormID: "production_report", DefaultFields: []string{"report_no", "work_order_no", "reported_qty", "qualified_qty", "shift_name"}, FilterHint: "例如:reported_qty > 0"},
},
},
"mail163_input": {
Key: "mail163_input",
Label: "163 邮件输入连接器",
Vendor: "NetEase",
Category: "email",
Direction: "input",
Description: "通过 IMAP 读取 163 邮箱中的邮件主题、正文摘要、附件与发件人。",
Mode: "demo",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"read", "imap", "attachment"},
Actions: []ActionDefinition{
{Key: "fetch_inbox_mail", Label: "读取收件箱", ActionType: "FetchInboxMail", Description: "拉取收件箱邮件列表"},
{Key: "fetch_unread_mail", Label: "读取未读邮件", ActionType: "FetchUnreadMail", Description: "读取未读邮件与摘要"},
{Key: "fetch_mail_attachment", Label: "读取邮件附件", ActionType: "FetchMailAttachment", Description: "拉取指定邮件的附件元数据"},
},
Objects: []ObjectDefinition{
{Key: "inbox_message", Label: "收件箱邮件", Description: "读取收件箱邮件列表", Mode: "read", RecommendedFormID: "INBOX", DefaultFields: []string{"message_id", "from", "subject", "received_at", "has_attachment"}, FilterHint: "例如:UNSEEN"},
{Key: "unread_message", Label: "未读邮件", Description: "读取未读邮件和优先级", Mode: "read", RecommendedFormID: "UNSEEN", DefaultFields: []string{"message_id", "from", "subject", "priority", "received_at"}, FilterHint: "例如:SINCE 16-Aug-2026"},
{Key: "mail_attachment", Label: "邮件附件", Description: "读取邮件附件元数据", Mode: "read", RecommendedFormID: "ATTACHMENT", DefaultFields: []string{"message_id", "attachment_name", "attachment_size", "content_type"}, FilterHint: "例如:HASATTACHMENT"},
},
},
"dingtalk_table_input": {
Key: "dingtalk_table_input",
Label: "钉钉表格输入连接器",
Vendor: "DingTalk",
Category: "ai_table",
Direction: "input",
Description: "从钉钉 AI 表格读取 Base、Sheet、Field、Record 结构化数据。",
Mode: "demo",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"read", "record", "sheet"},
Actions: []ActionDefinition{
{Key: "list_table_records", Label: "读取表格记录", ActionType: "ListTableRecords", Description: "读取指定表中的多行记录"},
{Key: "get_table_schema", Label: "读取表结构", ActionType: "GetTableSchema", Description: "读取字段定义与主字段"},
},
Objects: []ObjectDefinition{
{Key: "sheet_record", Label: "表格记录", Description: "读取 AI 表格记录", Mode: "read", RecommendedFormID: "sheet_record", DefaultFields: []string{"record_id", "sheet_name", "title", "owner", "status"}, FilterHint: "例如:status = '进行中'"},
{Key: "field_schema", Label: "字段结构", Description: "读取 AI 表格字段结构", Mode: "read", RecommendedFormID: "field_schema", DefaultFields: []string{"field_id", "field_name", "field_type", "required"}, FilterHint: "例如:field_type = 'singleSelect'"},
},
},
"feishu_bitable_input": {
Key: "feishu_bitable_input",
Label: "飞书表格输入连接器",
Vendor: "Feishu",
Category: "bitable",
Direction: "input",
Description: "从飞书多维表格读取记录、视图和字段,适合业务系统型数据输入。",
Mode: "demo",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"read", "record", "bitable"},
Actions: []ActionDefinition{
{Key: "list_bitable_records", Label: "读取多维表记录", ActionType: "ListBitableRecords", Description: "读取飞书多维表格记录"},
{Key: "get_bitable_fields", Label: "读取字段定义", ActionType: "GetBitableFields", Description: "读取字段配置和类型"},
},
Objects: []ObjectDefinition{
{Key: "bitable_record", Label: "多维表记录", Description: "读取业务记录与状态字段", Mode: "read", RecommendedFormID: "bitable_record", DefaultFields: []string{"record_id", "table_name", "title", "owner", "status"}, FilterHint: "例如:status = '待跟进'"},
{Key: "field_schema", Label: "字段结构", Description: "读取字段与权限结构", Mode: "read", RecommendedFormID: "field_schema", DefaultFields: []string{"field_id", "field_name", "field_type", "is_primary"}, FilterHint: "例如:field_type = 'Text'"},
},
},
"wecom_sheet_input": {
Key: "wecom_sheet_input",
Label: "企微表格输入连接器",
Vendor: "WeCom",
Category: "smart_sheet",
Direction: "input",
Description: "从企业微信智能表格读取记录、视图和自动化上下文。",
Mode: "demo",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"read", "record", "smart_sheet"},
Actions: []ActionDefinition{
{Key: "list_sheet_records", Label: "读取智能表格记录", ActionType: "ListSheetRecords", Description: "读取企微智能表格记录"},
{Key: "get_sheet_views", Label: "读取视图定义", ActionType: "GetSheetViews", Description: "读取表格视图和筛选规则"},
},
Objects: []ObjectDefinition{
{Key: "sheet_record", Label: "智能表格记录", Description: "读取行级业务记录", Mode: "read", RecommendedFormID: "sheet_record", DefaultFields: []string{"record_id", "sheet_name", "title", "owner", "progress"}, FilterHint: "例如:progress != '已完成'"},
{Key: "view_schema", Label: "视图定义", Description: "读取视图与权限信息", Mode: "read", RecommendedFormID: "view_schema", DefaultFields: []string{"view_id", "view_name", "view_type", "permission_scope"}, FilterHint: "例如:view_type = 'kanban'"},
},
},
"dingtalk_table_output": {
Key: "dingtalk_table_output",
Label: "钉钉表格输出连接器",
Vendor: "DingTalk",
Category: "ai_table",
Direction: "output",
Description: "将 DW 结果写入钉钉 AI 表格,适合落库、回填和经营看板。",
Mode: "blueprint",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"write", "upsert", "record"},
Actions: []ActionDefinition{
{Key: "insert_table_record", Label: "新增记录", ActionType: "InsertTableRecord", Description: "向钉钉 AI 表格插入记录"},
{Key: "update_table_record", Label: "更新记录", ActionType: "UpdateTableRecord", Description: "更新现有记录"},
{Key: "append_result_report", Label: "追加结果报表", ActionType: "AppendResultReport", Description: "写入报表汇总行"},
},
Objects: []ObjectDefinition{
{Key: "sheet_record", Label: "表格记录", Description: "写入业务结果记录", Mode: "write", RecommendedFormID: "sheet_record", DefaultFields: []string{"title", "owner", "status", "updated_at"}, FilterHint: "按主键 upsert"},
},
},
"feishu_bitable_output": {
Key: "feishu_bitable_output",
Label: "飞书表格输出连接器",
Vendor: "Feishu",
Category: "bitable",
Direction: "output",
Description: "将 DW 结果写入飞书多维表格,适合线索池、任务池和日报沉淀。",
Mode: "blueprint",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"write", "upsert", "record"},
Actions: []ActionDefinition{
{Key: "insert_bitable_record", Label: "新增记录", ActionType: "InsertBitableRecord", Description: "向飞书多维表格新增记录"},
{Key: "update_bitable_record", Label: "更新记录", ActionType: "UpdateBitableRecord", Description: "更新多维表格记录"},
{Key: "sync_result_view", Label: "同步结果视图", ActionType: "SyncResultView", Description: "将结果同步到指定视图"},
},
Objects: []ObjectDefinition{
{Key: "bitable_record", Label: "多维表记录", Description: "写入业务结果记录", Mode: "write", RecommendedFormID: "bitable_record", DefaultFields: []string{"title", "owner", "status", "updated_at"}, FilterHint: "按 record_id 或业务主键 upsert"},
},
},
"wecom_sheet_output": {
Key: "wecom_sheet_output",
Label: "企微表格输出连接器",
Vendor: "WeCom",
Category: "smart_sheet",
Direction: "output",
Description: "将 DW 结果写入企业微信智能表格,适合表单回填、项目协同和门店运营。",
Mode: "blueprint",
Status: "blueprint",
AuthConfigured: false,
Capabilities: []string{"write", "upsert", "record"},
Actions: []ActionDefinition{
{Key: "insert_sheet_record", Label: "新增记录", ActionType: "InsertSheetRecord", Description: "向企微智能表格新增记录"},
{Key: "update_sheet_record", Label: "更新记录", ActionType: "UpdateSheetRecord", Description: "更新已有行"},
{Key: "notify_sheet_owner", Label: "通知表格负责人", ActionType: "NotifySheetOwner", Description: "结果写入后通知负责人"},
},
Objects: []ObjectDefinition{
{Key: "sheet_record", Label: "智能表格记录", Description: "写入业务结果记录", Mode: "write", RecommendedFormID: "sheet_record", DefaultFields: []string{"title", "owner", "progress", "updated_at"}, FilterHint: "按业务键 upsert"},
},
},
}
var staticDemoRows = map[string]map[string][]map[string]any{
"wms_input": {
"inventory_snapshot": {
{"warehouse_code": "WH-SZ-01", "location_code": "A-01-01", "sku": "MAT-1001", "on_hand_qty": 860, "available_qty": 820},
{"warehouse_code": "WH-SZ-01", "location_code": "B-03-06", "sku": "MAT-1002", "on_hand_qty": 96, "available_qty": 80},
},
"inbound_notice": {
{"notice_no": "IBN-20260816-001", "supplier_name": "精密零部件供应商", "warehouse_code": "WH-SZ-01", "eta_date": "2026-08-17", "status": "pending"},
},
"outbound_task": {
{"task_no": "OBT-20260816-008", "order_no": "SO20260816001", "warehouse_code": "WH-SZ-01", "pick_status": "picking", "carrier": "SF"},
},
},
"mes_input": {
"work_order": {
{"work_order_no": "MO-20260816-001", "product_code": "FG-8821", "line_code": "LINE-03", "planned_qty": 1200, "status": "running"},
{"work_order_no": "MO-20260816-002", "product_code": "FG-8822", "line_code": "LINE-01", "planned_qty": 500, "status": "released"},
},
"equipment_alarm": {
{"alarm_no": "ALM-03-091", "equipment_code": "SMT-03", "alarm_level": "high", "alarm_time": "2026-08-16T09:26:00+08:00", "status": "open"},
},
"production_report": {
{"report_no": "PR-20260816-03A", "work_order_no": "MO-20260816-001", "reported_qty": 480, "qualified_qty": 468, "shift_name": "白班"},
},
},
"mail163_input": {
"inbox_message": {
{"message_id": "<msg-1001@163.com>", "from": "buyer-a@example.com", "subject": "本周交期确认", "received_at": "2026-08-16T08:11:00+08:00", "has_attachment": true},
{"message_id": "<msg-1002@163.com>", "from": "finance@example.com", "subject": "8月回款对账单", "received_at": "2026-08-16T10:05:00+08:00", "has_attachment": false},
},
"unread_message": {
{"message_id": "<msg-1003@163.com>", "from": "factory@example.com", "subject": "产线停机预警", "priority": "high", "received_at": "2026-08-16T10:21:00+08:00"},
},
"mail_attachment": {
{"message_id": "<msg-1001@163.com>", "attachment_name": "需求清单.xlsx", "attachment_size": 245760, "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
},
},
"dingtalk_table_input": {
"sheet_record": {
{"record_id": "rec_dt_001", "sheet_name": "商机跟进", "title": "华北项目推进", "owner": "王敏", "status": "进行中"},
{"record_id": "rec_dt_002", "sheet_name": "售后工单", "title": "客户现场异常", "owner": "刘浩", "status": "待处理"},
},
"field_schema": {
{"field_id": "fld_dt_001", "field_name": "负责人", "field_type": "user", "required": true},
{"field_id": "fld_dt_002", "field_name": "状态", "field_type": "singleSelect", "required": true},
},
},
"feishu_bitable_input": {
"bitable_record": {
{"record_id": "rec_fs_001", "table_name": "客户线索池", "title": "深圳制造业客户", "owner": "李晓", "status": "待跟进"},
{"record_id": "rec_fs_002", "table_name": "合同审查任务", "title": "框架协议审查", "owner": "陈珂", "status": "审核中"},
},
"field_schema": {
{"field_id": "fld_fs_001", "field_name": "状态", "field_type": "SingleSelect", "is_primary": false},
{"field_id": "fld_fs_002", "field_name": "标题", "field_type": "Text", "is_primary": true},
},
},
"wecom_sheet_input": {
"sheet_record": {
{"record_id": "rec_wc_001", "sheet_name": "门店巡检", "title": "福田门店巡检", "owner": "赵琴", "progress": "待复核"},
{"record_id": "rec_wc_002", "sheet_name": "销售日报", "title": "华东区日报", "owner": "郭鹏", "progress": "已提交"},
},
"view_schema": {
{"view_id": "view_wc_001", "view_name": "本周待办", "view_type": "kanban", "permission_scope": "sales_team"},
{"view_id": "view_wc_002", "view_name": "门店异常", "view_type": "table", "permission_scope": "ops_team"},
},
},
}
@@ -0,0 +1,94 @@
package connector
import (
"context"
"strings"
"eaisalestrain/backend/internal/config"
)
type Definition struct {
Key string `json:"key"`
Label string `json:"label"`
Vendor string `json:"vendor"`
Category string `json:"category"`
Direction string `json:"direction"`
Description string `json:"description"`
Mode string `json:"mode"`
Status string `json:"status"`
AuthConfigured bool `json:"auth_configured"`
Capabilities []string `json:"capabilities"`
Actions []ActionDefinition `json:"actions"`
Objects []ObjectDefinition `json:"objects"`
}
type ActionDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
ActionType string `json:"action_type"`
Description string `json:"description"`
}
type ObjectDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
Mode string `json:"mode"`
RecommendedFormID string `json:"recommended_form_id"`
DefaultFields []string `json:"default_fields"`
FilterHint string `json:"filter_hint"`
}
type QueryRequest struct {
ObjectKey string `json:"object_key"`
FormID string `json:"form_id"`
FieldKeys []string `json:"field_keys"`
FilterString string `json:"filter_string"`
OrderString string `json:"order_string"`
StartRow int `json:"start_row"`
Limit int `json:"limit"`
UseDemo bool `json:"use_demo"`
Filters map[string]any `json:"filters"`
}
type QueryResult struct {
ConnectorKey string `json:"connector_key"`
ConnectorLabel string `json:"connector_label"`
ObjectKey string `json:"object_key"`
ObjectLabel string `json:"object_label"`
Mode string `json:"mode"`
Request map[string]any `json:"request"`
Outputs map[string]any `json:"outputs"`
Artifacts []map[string]any `json:"artifacts"`
Citations []map[string]any `json:"citations"`
Risks []map[string]any `json:"risks"`
}
func ListDefinitions(cfg *config.Config) []Definition {
definitions := registry(cfg)
items := make([]Definition, 0, len(connectorOrder))
for _, key := range connectorOrder {
if item, ok := definitions[key]; ok {
items = append(items, item)
}
}
return items
}
func GetDefinition(cfg *config.Config, key string) (Definition, bool) {
key = strings.TrimSpace(strings.ToLower(key))
item, ok := registry(cfg)[key]
return item, ok
}
func Query(ctx context.Context, cfg *config.Config, key string, req QueryRequest) (QueryResult, error) {
key = strings.TrimSpace(strings.ToLower(key))
switch key {
case "kingdee":
return NewKingdee(cfg).Query(ctx, req)
case "wms_input", "mes_input", "mail163_input", "dingtalk_table_input", "feishu_bitable_input", "wecom_sheet_input":
return queryStaticConnector(cfg, key, req)
default:
return QueryResult{}, ErrConnectorNotFound
}
}
@@ -0,0 +1,103 @@
package middleware
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// Auth 解析 JWT → 校验用户 → 注入 current_user
func Auth(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr := extractBearer(c)
if tokenStr == "" {
web.Fail(c, web.NewAuthError("缺少 Authorization Bearer 令牌"))
c.Abort()
return
}
claims, err := auth.ParseToken(tokenStr, cfg.JWTSecret)
if err != nil {
web.Fail(c, web.NewAuthError("令牌无效或已过期"))
c.Abort()
return
}
username, _ := claims["sub"].(string)
if username == "" {
web.Fail(c, web.NewAuthError("令牌中缺少用户标识"))
c.Abort()
return
}
var user model.User
if err := store.DB.Where("username = ?", username).First(&user).Error; err != nil {
web.Fail(c, web.NewAuthError("用户不存在"))
c.Abort()
return
}
if user.Status != "active" {
web.Fail(c, web.NewAuthError("账号已禁用"))
c.Abort()
return
}
c.Set("current_user", &user)
// #region debug-point C:auth-success
go func(path, method, username, role string, userID uint) { _, _ = http.Post("http://127.0.0.1:7777/event", "application/json", bytes.NewBuffer(mustJSON(map[string]any{"sessionId": "admin-auth-misjudge", "runId": "pre-fix", "hypothesisId": "C", "location": "internal/middleware/auth.go:Auth", "msg": "[DEBUG] auth accepted request", "data": map[string]any{"path": path, "method": method, "username": username, "role": role, "user_id": userID, "token_present": true}, "ts": time.Now().UnixMilli()}))) }(c.Request.URL.Path, c.Request.Method, user.Username, user.Role, user.ID)
// #endregion
c.Next()
}
}
// RequireAdmin 管理员角色守卫(须在 Auth 之后)
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
u := CurrentUser(c)
if u == nil || u.Role != "admin" {
// #region debug-point D:require-admin-reject
go func(path, method string, u *model.User) { payload := map[string]any{"sessionId": "admin-auth-misjudge", "runId": "pre-fix", "hypothesisId": "D", "location": "internal/middleware/auth.go:RequireAdmin", "msg": "[DEBUG] require admin rejected request", "data": map[string]any{"path": path, "method": method, "user_nil": u == nil}, "ts": time.Now().UnixMilli()}; if u != nil { payload["data"] = map[string]any{"path": path, "method": method, "user_nil": false, "username": u.Username, "role": u.Role, "user_id": u.ID} }; _, _ = http.Post("http://127.0.0.1:7777/event", "application/json", bytes.NewBuffer(mustJSON(payload))) }(c.Request.URL.Path, c.Request.Method, u)
// #endregion
web.Fail(c, web.NewForbiddenError("需要管理员权限"))
c.Abort()
return
}
c.Next()
}
}
// CurrentUser 从 context 取当前用户(可能为 nil)
func CurrentUser(c *gin.Context) *model.User {
if v, ok := c.Get("current_user"); ok {
if u, ok := v.(*model.User); ok {
return u
}
}
return nil
}
func extractBearer(c *gin.Context) string {
h := c.GetHeader("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(h[len("Bearer "):])
}
return ""
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte(`{"sessionId":"admin-auth-misjudge","runId":"pre-fix","hypothesisId":"D","location":"internal/middleware/auth.go:mustJSON","msg":"[DEBUG] marshal failed","ts":0}`)
}
return b
}
@@ -0,0 +1,23 @@
package model
import "time"
// AiCallLog AI 调用日志(审计 + 按用户算力点计费)
// 精简自 pj034 ai_call_logs:去掉 company_id / input_asset / cost_cny / billing_mode 等电商与多租户字段。
type AiCallLog struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Capability string `gorm:"size:32;not null;index" json:"capability"` // ai_chat / text_gen / embed
Provider string `gorm:"size:32" json:"provider"`
RouteID string `gorm:"size:64" json:"route_id"`
Model string `gorm:"size:64" json:"model"`
TokensInput int `gorm:"default:0" json:"tokens_input"`
TokensOutput int `gorm:"default:0" json:"tokens_output"`
CreditsCharged int `gorm:"default:0" json:"credits_charged"` // 实扣点数(0 = 未扣)
Status string `gorm:"size:16;not null;index" json:"status"` // success / failed
ErrorMessage string `gorm:"size:512" json:"error_message,omitempty"`
LatencyMs int `gorm:"default:0" json:"latency_ms"`
CreatedAt time.Time `json:"created_at"`
}
func (AiCallLog) TableName() string { return "ai_call_log" }
@@ -0,0 +1,20 @@
package model
import "time"
// Certificate 考试合格证书:正式考试通过后自动颁发,供培训验收与留存。
// 每张证书对应一条正式考试记录(同一学员同一试卷仅一次正式考,故不会重复发证)。
type Certificate struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
ExamRecordID uint `gorm:"not null;index" json:"exam_record_id"`
UserName string `gorm:"size:64;not null" json:"user_name"`
ExamName string `gorm:"size:128;not null" json:"exam_name"`
Score int `gorm:"not null" json:"score"`
TotalScore int `gorm:"not null" json:"total_score"`
PassScore int `gorm:"not null" json:"pass_score"`
CertNo string `gorm:"size:64;not null;uniqueIndex" json:"cert_no"`
IssuedAt time.Time `gorm:"not null" json:"issued_at"`
}
func (Certificate) TableName() string { return "certificate" }
@@ -0,0 +1,24 @@
package model
import "time"
// Course 课程表
type Course struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:32;uniqueIndex;not null" json:"code"`
Name string `gorm:"size:128;not null" json:"name"`
Category string `gorm:"size:32;not null;index" json:"category"` // capital_script/qualification_logic/ai_public_course/ai_platform_matching
TargetCustomers string `gorm:"type:text" json:"target_customers"`
ForbiddenCustomers string `gorm:"type:text" json:"forbidden_customers"`
Scripts string `gorm:"type:text" json:"scripts"`
SalesProcess string `gorm:"type:text" json:"sales_process"`
ObjectionHandling string `gorm:"type:text" json:"objection_handling"`
DeliveryPitfalls string `gorm:"type:text" json:"delivery_pitfalls"`
ReportRules string `gorm:"type:text" json:"report_rules"`
RelatedProductID *uint `gorm:"index" json:"related_product_id"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Course) TableName() string { return "course" }
@@ -0,0 +1,16 @@
package model
import "time"
// Department 部门字典表:企业组织架构,供用户「部门」字段选择与按部门学情统计。
// 用户以 user.department 字符串归属(历史兼容),部门改名时后端同步该字符串。
type Department struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null;uniqueIndex" json:"name"`
Description string `gorm:"size:255;default:''" json:"description"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Department) TableName() string { return "department" }
@@ -0,0 +1,22 @@
package model
import "time"
// ExamPaper 考试配置表
type ExamPaper struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Type string `gorm:"size:16;not null;index" json:"type"` // self_test / formal
Domain string `gorm:"size:64;not null" json:"domain"` // 抽题知识域,逗号分隔
QuestionCount int `gorm:"not null" json:"question_count"`
TotalScore int `gorm:"not null" json:"total_score"`
PassScore int `gorm:"not null" json:"pass_score"`
DurationMinutes int `gorm:"not null" json:"duration_minutes"`
Randomize bool `gorm:"not null" json:"randomize"`
PositionID *uint `gorm:"index" json:"position_id"` // 关联岗位考试,可空;设置后按岗位知识映射抽题
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ExamPaper) TableName() string { return "exam_paper" }
@@ -0,0 +1,21 @@
package model
import "time"
// ExamRecord 考试记录表
type ExamRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
PaperID uint `gorm:"not null;index" json:"paper_id"`
ExamName string `gorm:"size:128;not null" json:"exam_name"`
Score int `gorm:"not null" json:"score"`
TotalScore int `gorm:"not null" json:"total_score"`
PassScore int `gorm:"not null" json:"pass_score"`
Passed bool `gorm:"not null;index" json:"passed"`
CorrectCount int `gorm:"not null" json:"correct_count"`
WrongCount int `gorm:"not null" json:"wrong_count"`
DetailJSON string `gorm:"type:text;not null" json:"detail_json"` // 答题明细 JSON
SubmittedAt time.Time `gorm:"not null;index" json:"submitted_at"`
}
func (ExamRecord) TableName() string { return "exam_record" }
@@ -0,0 +1,17 @@
package model
import "time"
// KnowledgeChunk 知识块表(AI 检索最小单元)
type KnowledgeChunk struct {
ID uint `gorm:"primaryKey" json:"id"`
MediaFileID *uint `gorm:"index" json:"media_file_id"` // 与 knowledge_source_id 二选一
KnowledgeSourceID *uint `gorm:"index" json:"knowledge_source_id"` // 与 media_file_id 二选一
SourceType string `gorm:"size:32;not null" json:"source_type"` // pdf/doc/md
SourceID string `gorm:"size:64" json:"source_id"`
ChunkIndex int `gorm:"not null" json:"chunk_index"`
Content string `gorm:"type:text;not null" json:"content"`
CreatedAt time.Time `json:"created_at"`
}
func (KnowledgeChunk) TableName() string { return "knowledge_chunk" }
@@ -0,0 +1,22 @@
package model
import "time"
// KnowledgeSource 结构化知识源表
type KnowledgeSource struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:256;not null" json:"title"`
FilePath string `gorm:"size:512;uniqueIndex;not null" json:"file_path"`
Category string `gorm:"size:64;not null;index" json:"category"` // general/capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform
Domain string `gorm:"size:16;not null;default:product" json:"domain"` // company / product / sales
SourceVersion string `gorm:"size:32;not null" json:"source_version"`
AuditStatus string `gorm:"size:16;not null;default:pending;index" json:"audit_status"` // pending / approved / rejected
AuditBy *uint `json:"audit_by"`
AuditAt *time.Time `json:"audit_at"`
RejectReason string `gorm:"size:512" json:"reject_reason"`
Ingested bool `gorm:"not null;default:false" json:"ingested"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (KnowledgeSource) TableName() string { return "knowledge_source" }
@@ -0,0 +1,15 @@
package model
import "time"
// LearningProgress 学习进度表:记录员工是否已浏览公司介绍 / 产品 / 课程
type LearningProgress struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_learning;not null" json:"user_id"`
ItemType string `gorm:"uniqueIndex:idx_learning;size:16;not null" json:"item_type"` // company / product / course
ItemID uint `gorm:"uniqueIndex:idx_learning;not null" json:"item_id"` // company 固定 0
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (LearningProgress) TableName() string { return "learning_progress" }
@@ -0,0 +1,26 @@
package model
import "time"
// MediaFile 素材文件表
type MediaFile struct {
ID uint `gorm:"primaryKey" json:"id"`
Filename string `gorm:"size:256;not null" json:"filename"`
StoredName string `gorm:"size:64;not null" json:"stored_name"`
StoredPath string `gorm:"size:512;not null" json:"stored_path"`
FileExt string `gorm:"size:16;not null" json:"file_ext"`
FileSize int64 `gorm:"not null" json:"file_size"`
Status string `gorm:"size:16;not null;default:pending;index" json:"status"` // pending / approved / rejected
Source string `gorm:"size:16;not null" json:"source"` // employee / admin
SubmitterID uint `gorm:"not null;index" json:"submitter_id"`
BindType string `gorm:"size:16;not null;default:none" json:"bind_type"` // company / product / course / none
BindID *uint `gorm:"index" json:"bind_id"`
Remark string `gorm:"size:512" json:"remark"` // 提交备注(员工素材建议)
RejectReason string `gorm:"size:512" json:"reject_reason"`
AuditBy *uint `json:"audit_by"`
AuditAt *time.Time `json:"audit_at"`
Extracted bool `gorm:"not null;default:false;index" json:"extracted"`
CreatedAt time.Time `json:"created_at"`
}
func (MediaFile) TableName() string { return "media_file" }
@@ -0,0 +1,23 @@
package model
import "time"
// MistakeRecord 错题本(学员自助工具,非学情分析)
// 按 (user_id, question_id, source) 去重:同一学员同一题同一种考试只保留一条,
// 再次答错时更新作答与解析并将 resolved 重置为 false。
type MistakeRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
QuestionID uint `gorm:"not null;index" json:"question_id"`
Source string `gorm:"size:16;not null;index" json:"source"` // self_test / formal
QuestionType string `gorm:"size:16;not null" json:"question_type"` // single/multiple/judge/essay(冗余,供错题本独立展示)
QuestionStem string `gorm:"type:text;not null" json:"question_stem"`
UserAnswer string `gorm:"type:text" json:"user_answer"` // JSON 序列化的作答
CorrectAnswer string `gorm:"type:text" json:"correct_answer"` // JSON 序列化的正确答案 / 评分标准
Explanation string `gorm:"type:text;default:''" json:"explanation"`
Resolved bool `gorm:"not null;default:false;index" json:"resolved"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (MistakeRecord) TableName() string { return "mistake_record" }
@@ -0,0 +1,17 @@
package model
import "time"
// Notification 站内消息通知:考试发布 / 考试通过发证 / 岗位设置等事件推送给学员。
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Type string `gorm:"size:32;not null;index" json:"type"` // exam_publish / exam_pass / position_set
Title string `gorm:"size:128;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Link string `gorm:"size:128;default:''" json:"link"` // 前端跳转路由
Read bool `gorm:"not null;default:false;index" json:"read"`
CreatedAt time.Time `json:"created_at"`
}
func (Notification) TableName() string { return "notification" }
@@ -0,0 +1,17 @@
package model
import "time"
// PointEvent 学习积分流水(游戏化成长值,逐笔可审计)
// 用户学习/考试/错题掌握等行为触发加分,积分总额冗余在 user.learning_points。
type PointEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
EventType string `gorm:"size:32;not null;index" json:"event_type"` // first_company/first_product/first_course/self_test/formal_pass/mistake_resolved
Points int `gorm:"not null" json:"points"`
RefType string `gorm:"size:32;default:''" json:"ref_type"` // company/product/course/paper/mistake
RefID uint `gorm:"default:0" json:"ref_id"`
CreatedAt time.Time `json:"created_at"`
}
func (PointEvent) TableName() string { return "point_event" }
@@ -0,0 +1,27 @@
package model
import "time"
// 岗位知识要求级别(对齐 pj006 的 L1-L4 词汇)
const (
LevelL1 = "L1" // 入门级:概念/定义/标准流程
LevelL2 = "L2" // 稳定执行级:标准应用/独立执行
LevelL3 = "L3" // 复杂处理级:跨场景迁移/带教
LevelL4 = "L4" // 体系策略级:方案设计/组织约束
)
// ValidLevels 合法级别集合
var ValidLevels = map[string]bool{LevelL1: true, LevelL2: true, LevelL3: true, LevelL4: true}
// Position 岗位表
type Position struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:32;uniqueIndex;not null" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Position) TableName() string { return "position" }
@@ -0,0 +1,18 @@
package model
import "time"
// PositionExamBlueprint 岗位考试蓝图:规定岗位考试的题型构成(按域+题型各抽多少题)。
// 题目本身不携带 L1-L4 级别字段,故蓝图按 domain + type 维度抽样,不做级别加权。
// 蓝图存在时,pickQuestions 优先按蓝图组卷;否则回退到岗位知识映射圈定题池抽题。
type PositionExamBlueprint struct {
ID uint `gorm:"primaryKey" json:"id"`
PositionID uint `gorm:"not null;index" json:"position_id"`
Domain string `gorm:"size:16;not null;default:''" json:"domain"` // company/product/sales,空=不限(在岗位范围内)
Type string `gorm:"size:16;not null" json:"type"` // single/multiple/judge/essay
Count int `gorm:"not null" json:"count"` // 该域该题型抽取数量
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (PositionExamBlueprint) TableName() string { return "position_exam_blueprint" }
@@ -0,0 +1,20 @@
package model
import "time"
// PositionKnowledge 岗位知识要求(岗位 ↔ 知识域/课程/产品 映射)
// domain 必填;course_id / product_id 可选(三者共同圈定岗位应学范围)。
type PositionKnowledge struct {
ID uint `gorm:"primaryKey" json:"id"`
PositionID uint `gorm:"not null;index" json:"position_id"`
Domain string `gorm:"size:16;not null;index" json:"domain"` // company / product / sales
CourseID *uint `gorm:"index" json:"course_id"` // 绑定具体课程,可空
ProductID *uint `gorm:"index" json:"product_id"` // 绑定具体产品,可空
RequiredLevel string `gorm:"size:16;not null;default:L1" json:"required_level"` // L1/L2/L3/L4
Weight float64 `gorm:"not null;default:1" json:"weight"`
IsMandatory bool `gorm:"not null;default:true" json:"is_mandatory"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (PositionKnowledge) TableName() string { return "position_knowledge" }
@@ -0,0 +1,24 @@
package model
import "time"
// Product 产品表
type Product struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:32;uniqueIndex;not null" json:"code"`
Name string `gorm:"size:128;not null" json:"name"`
Category string `gorm:"size:32;not null;index" json:"category"` // capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform
Tags string `gorm:"size:256;default:''" json:"tags"`
Description string `gorm:"type:text" json:"description"`
Pricing string `gorm:"type:text" json:"pricing"`
CommissionRecommend string `gorm:"size:128;default:''" json:"commission_recommend"`
CommissionNegotiate string `gorm:"size:128;default:''" json:"commission_negotiate"`
PublicCourseBonus string `gorm:"size:128;default:''" json:"public_course_bonus"`
VersionRisk string `gorm:"type:text" json:"version_risk"`
ReportRules string `gorm:"type:text" json:"report_rules"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Product) TableName() string { return "product" }
@@ -0,0 +1,20 @@
package model
import "time"
// Question 题目表
type Question struct {
ID uint `gorm:"primaryKey" json:"id"`
Domain string `gorm:"size:16;not null;index" json:"domain"` // company / product / sales
CourseID *uint `gorm:"index" json:"course_id"`
Type string `gorm:"size:16;not null" json:"type"` // single / multiple / judge
Stem string `gorm:"type:text;not null" json:"stem"`
Options string `gorm:"type:text;not null" json:"options"` // JSON 数组:["A","B","C"]
Answer string `gorm:"type:text;not null" json:"answer"` // JSON 数组:single=[索引] multiple=[索引...] judge=[bool]
Explanation string `gorm:"type:text;default:''" json:"explanation"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Question) TableName() string { return "question" }
@@ -0,0 +1,38 @@
package model
import "time"
// Specialist 数字员工专员目录
type Specialist struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
Label string `gorm:"size:128;not null" json:"label"`
Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry
WorkerType string `gorm:"size:16;not null;default:dw;index" json:"worker_type"`
Route string `gorm:"size:128;not null" json:"route"`
Summary string `gorm:"type:text" json:"summary"`
WorkStatus string `gorm:"size:64;default:''" json:"work_status"`
RiskLabel string `gorm:"size:64;default:''" json:"risk_label"`
Color string `gorm:"size:16;default:''" json:"color"`
Stage string `gorm:"size:64;default:''" json:"stage"`
Progress int `gorm:"not null;default:0" json:"progress"`
MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用
Version string `gorm:"size:32;default:''" json:"version"`
ConnectorScope string `gorm:"type:text" json:"connector_scope"`
PermissionScope string `gorm:"type:text" json:"permission_scope"`
ResourceBindings string `gorm:"type:text" json:"resource_bindings"`
InfoSources string `gorm:"type:text" json:"info_sources"`
BaseSkills string `gorm:"type:text" json:"base_skills"`
AIAssistance string `gorm:"type:text" json:"ai_assistance"`
GeneratedSkills string `gorm:"type:text" json:"generated_skills"`
InputsRecordsJSON string `gorm:"column:source_records_json;type:text" json:"inputs_records_json"`
PermissionRecordsJSON string `gorm:"type:text" json:"permission_records_json"`
ActionRecordsJSON string `gorm:"type:text" json:"action_records_json"`
ResultRecordsJSON string `gorm:"type:text" json:"result_records_json"`
State string `gorm:"size:16;not null;default:active;index" json:"state"` // active / inactive
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Specialist) TableName() string { return "specialist" }
@@ -0,0 +1,16 @@
package model
import "time"
// StudyNote 学习笔记:员工在浏览公司介绍 / 产品 / 课程时记录的私人学习笔记。
type StudyNote struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
ItemType string `gorm:"size:16;not null;index" json:"item_type"` // company / product / course
ItemID uint `gorm:"not null;index" json:"item_id"` // company 固定 0
Content string `gorm:"type:text;not null" json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (StudyNote) TableName() string { return "study_note" }
@@ -0,0 +1,14 @@
package model
import "time"
// SystemConfig 系统参数配置表
type SystemConfig struct {
ID uint `gorm:"primaryKey" json:"id"`
ConfigKey string `gorm:"size:64;uniqueIndex;not null" json:"config_key"`
ConfigValue string `gorm:"type:text;not null" json:"config_value"`
Description string `gorm:"size:256;default:''" json:"description"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SystemConfig) TableName() string { return "system_config" }
@@ -0,0 +1,23 @@
package model
import "time"
// User 用户表
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:256;not null" json:"-"`
FullName string `gorm:"size:64;not null" json:"full_name"`
Role string `gorm:"size:16;not null;default:employee;index" json:"role"` // employee / admin
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / disabled
AiPoints int `gorm:"not null;default:100" json:"ai_points"` // 剩余 AI 算力点
LearningPoints int `gorm:"not null;default:0;index" json:"learning_points"` // 学习积分(游戏化成长值)
Department string `gorm:"size:64;default:''" json:"department"` // 部门
Position string `gorm:"size:64;default:''" json:"position"` // 岗位(文本,历史遗留)
PositionID *uint `gorm:"index" json:"position_id"` // 所属岗位,可空(历史用户/管理员可为空)
HireBatch string `gorm:"size:64;default:''" json:"hire_batch"` // 入职批次
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (User) TableName() string { return "user" }
@@ -0,0 +1,62 @@
package store
import (
"os"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"eaisalestrain/backend/internal/model"
)
// DB 全局数据库句柄
var DB *gorm.DB
// Init 打开 SQLite(pure-Go 驱动,CGO_ENABLED=0 可静态编译)并 AutoMigrate 全部表。
// 通过 GORM 方言抽象,未来切换 MySQL 仅需替换驱动与 DSN。
func Init(dbPath string) (*gorm.DB, error) {
if dir := filepath.Dir(dbPath); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, err
}
if err := db.AutoMigrate(
&model.User{},
&model.Product{},
&model.Course{},
&model.MediaFile{},
&model.KnowledgeChunk{},
&model.Question{},
&model.ExamPaper{},
&model.ExamRecord{},
&model.SystemConfig{},
&model.KnowledgeSource{},
&model.AiCallLog{},
&model.LearningProgress{},
&model.Position{},
&model.PositionKnowledge{},
&model.PositionExamBlueprint{},
&model.MistakeRecord{},
&model.PointEvent{},
&model.Certificate{},
&model.Department{},
&model.Notification{},
&model.StudyNote{},
&model.Specialist{},
); err != nil {
return nil, err
}
DB = db
return db, nil
}
@@ -0,0 +1,649 @@
package store
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"eaisalestrain/backend/internal/auth"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/model"
)
// SeedDefaults 插入默认管理员 + system_config 默认项(幂等,可重复执行)
func SeedDefaults() error {
// 默认管理员 admin / admin123(交付前 Clonezilla 清理时改密)
var c int64
DB.Model(&model.User{}).Where("username = ?", "admin").Count(&c)
if c == 0 {
hash, err := auth.HashPassword("admin123")
if err != nil {
return err
}
admin := model.User{
Username: "admin",
PasswordHash: hash,
FullName: "系统管理员",
Role: "admin",
Status: "active",
AiPoints: 999999, // 管理员算力点默认不限
}
if err := DB.Create(&admin).Error; err != nil {
return err
}
log.Println("[OK] 管理员账号已创建(admin / admin123)")
}
if err := ensureDemoEmployee(); err != nil {
return err
}
// 管理员算力点兜底:历史管理员(自动迁移补列后为默认 100)抬到 999999 不限
DB.Model(&model.User{}).Where("role = ? AND ai_points <= 100", "admin").Update("ai_points", 999999)
defaults := []struct{ key, value, desc string }{
// 7.2.1 公司信息配置
{"company_intro", "", "公司介绍培训页内容(HTML/文本)"},
{"company_name", "博昇", "公司名称"},
{"admin_email", "", "管理员联系邮箱"},
// 7.2.2 文件与存储
{"file_max_size_doc", "209715200", "文档最大字节(200MB)"},
{"file_max_size_video", "2147483648", "视频最大字节(2GB)"},
{"chunk_threshold", "104857600", "分片上传阈值(100MB)"},
// 7.2.3 其他信息配置
{"jwt_expire_minutes", "480", "Token 过期分钟数"},
{"password_min_length", "6", "密码最小长度"},
{"session_timeout_minutes", "480", "会话超时分钟数(不活动自动退出)"},
// 7.2.4 AI 配置
{"llm_base_url", "http://localhost:11434/v1", "LLM 服务地址"},
{"llm_api_key", "sk-xxx", "LLM API Key(敏感信息,建议写入 ai_secrets.json)"},
{"llm_model", "qwen2.5:7b", "LLM 模型名"},
{"embed_model", "bge-m3", "Embedding 模型名"},
{"llm_max_tokens", "2048", "LLM 最大输出 Token 数"},
{"llm_temperature", "0.7", "LLM 温度参数(0.0~1.0)"},
{"ai_points_default", "100", "新用户默认 AI 算力点"},
}
for _, d := range defaults {
var n int64
DB.Model(&model.SystemConfig{}).Where("config_key = ?", d.key).Count(&n)
if n == 0 {
if err := DB.Create(&model.SystemConfig{
ConfigKey: d.key,
ConfigValue: d.value,
Description: d.desc,
}).Error; err != nil {
return err
}
}
}
if err := seedCoursesFromJSON(); err != nil {
return err
}
if err := seedTrainingMedia(); err != nil {
return err
}
if err := seedSpecialists(); err != nil {
return err
}
return nil
}
func ensureDemoEmployee() error {
var c int64
DB.Model(&model.User{}).Where("username = ?", "emp_test01").Count(&c)
if c > 0 {
return nil
}
hash, err := auth.HashPassword("test123456")
if err != nil {
return err
}
employee := model.User{
Username: "emp_test01",
PasswordHash: hash,
FullName: "测试员工",
Role: "employee",
Status: "active",
AiPoints: 100,
}
if err := DB.Create(&employee).Error; err != nil {
return err
}
log.Println("[OK] 演示员工账号已创建(emp_test01 / test123456)")
return nil
}
type courseSeedFile struct {
Data []courseSeedItem `json:"data"`
}
type courseSeedItem struct {
Code string `json:"code"`
Name string `json:"name"`
Category string `json:"category"`
TargetCustomers string `json:"target_customers"`
ForbiddenCustomers string `json:"forbidden_customers"`
Scripts string `json:"scripts"`
SalesProcess string `json:"sales_process"`
ObjectionHandling string `json:"objection_handling"`
DeliveryPitfalls string `json:"delivery_pitfalls"`
ReportRules string `json:"report_rules"`
}
func seedCoursesFromJSON() error {
var count int64
DB.Model(&model.Course{}).Count(&count)
if count > 0 {
return nil
}
path := firstExistingPath(
"training_materials/work/courses.json",
"../training_materials/work/courses.json",
"eaisalestrain_app/training_materials/work/courses.json",
)
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var payload courseSeedFile
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
for _, item := range payload.Data {
if item.Code == "" || item.Name == "" || item.Category == "" {
continue
}
course := model.Course{
Code: item.Code,
Name: item.Name,
Category: item.Category,
TargetCustomers: item.TargetCustomers,
ForbiddenCustomers: item.ForbiddenCustomers,
Scripts: item.Scripts,
SalesProcess: item.SalesProcess,
ObjectionHandling: item.ObjectionHandling,
DeliveryPitfalls: item.DeliveryPitfalls,
ReportRules: item.ReportRules,
Status: "active",
}
if err := DB.Create(&course).Error; err != nil {
return err
}
}
log.Println("[OK] 销售课程种子已导入")
return nil
}
type mediaSeed struct {
filename string
sourcePath string
storedName string
bindType string
bindCode string
}
func seedTrainingMedia() error {
cfg := config.Load()
approvedDir := filepath.Join(cfg.KBDataDir, "approved")
if err := os.MkdirAll(approvedDir, 0o755); err != nil {
return err
}
var admin model.User
if err := DB.Where("username = ?", "admin").First(&admin).Error; err != nil {
return err
}
courseIDs := map[string]uint{}
var courses []model.Course
DB.Where("status = ?", "active").Find(&courses)
for _, course := range courses {
courseIDs[course.Code] = course.ID
}
seeds := []mediaSeed{
{
filename: "01_公司介绍.mp4",
sourcePath: firstExistingPath("training_materials/mp4/01_公司介绍.mp4", "../training_materials/mp4/01_公司介绍.mp4", "eaisalestrain_app/training_materials/mp4/01_公司介绍.mp4"),
storedName: "seed_company_intro_video.mp4",
bindType: "company",
},
{
filename: "01_公司介绍.pdf",
sourcePath: firstExistingPath("training_materials/pdf/01_公司介绍.pdf", "../training_materials/pdf/01_公司介绍.pdf", "eaisalestrain_app/training_materials/pdf/01_公司介绍.pdf"),
storedName: "seed_company_intro_manual.pdf",
bindType: "company",
},
{
filename: "05_资本咨询销售要点.mp4",
sourcePath: firstExistingPath("training_materials/mp4/05_资本咨询销售要点.mp4", "../training_materials/mp4/05_资本咨询销售要点.mp4", "eaisalestrain_app/training_materials/mp4/05_资本咨询销售要点.mp4"),
storedName: "seed_course_co001_video.mp4",
bindType: "course",
bindCode: "CO-001",
},
{
filename: "03_高企认定八大条件.mp4",
sourcePath: firstExistingPath("training_materials/mp4/03_高企认定八大条件.mp4", "../training_materials/mp4/03_高企认定八大条件.mp4", "eaisalestrain_app/training_materials/mp4/03_高企认定八大条件.mp4"),
storedName: "seed_course_co002_video.mp4",
bindType: "course",
bindCode: "CO-002",
},
{
filename: "06_企业AI落地方法论.mp4",
sourcePath: firstExistingPath("training_materials/mp4/06_企业AI落地方法论.mp4", "../training_materials/mp4/06_企业AI落地方法论.mp4", "eaisalestrain_app/training_materials/mp4/06_企业AI落地方法论.mp4"),
storedName: "seed_course_co003_video.mp4",
bindType: "course",
bindCode: "CO-003",
},
{
filename: "03_资本咨询类产品手册.pdf",
sourcePath: firstExistingPath("training_materials/pdf/03_资本咨询类产品手册.pdf", "../training_materials/pdf/03_资本咨询类产品手册.pdf", "eaisalestrain_app/training_materials/pdf/03_资本咨询类产品手册.pdf"),
storedName: "seed_course_co001_manual.pdf",
bindType: "course",
bindCode: "CO-001",
},
{
filename: "05_AI咨询类产品手册.pdf",
sourcePath: firstExistingPath("training_materials/pdf/05_AI咨询类产品手册.pdf", "../training_materials/pdf/05_AI咨询类产品手册.pdf", "eaisalestrain_app/training_materials/pdf/05_AI咨询类产品手册.pdf"),
storedName: "seed_course_co003_manual.pdf",
bindType: "course",
bindCode: "CO-003",
},
}
for _, item := range seeds {
if item.sourcePath == "" {
continue
}
var bindID *uint
if item.bindType == "course" {
id, ok := courseIDs[item.bindCode]
if !ok {
continue
}
bindID = &id
}
if err := ensureSeedMedia(admin.ID, approvedDir, item, bindID); err != nil {
return err
}
}
return nil
}
func seedSpecialists() error {
items := []model.Specialist{
{
Key: "training-delivery",
Label: "培训交付专员",
Tier: "generic",
WorkerType: "dw",
Route: "/apps/training-delivery",
Summary: "新人销售训练营第 3 周",
WorkStatus: "5 个进行中",
RiskLabel: "0 个异常",
Color: "#67c23a",
Stage: "交付归档",
Progress: 84,
MarketTag: "已安装",
Version: "v1.1",
ConnectorScope: "连接课程系统 / 知识库 / 考试模块",
PermissionScope: "班级资料读取、考试结果读取、交付包生成",
ResourceBindings: "课程系统、考试模块、知识库、交付模板中心",
InfoSources: "班级排期、学员成绩、互动记录、教练点评",
BaseSkills: "班级日报、考试预警、复盘归档、交付清单生成",
AIAssistance: "自动总结班级状态并给出补练建议",
GeneratedSkills: "基于优秀班级复盘沉淀新训练营模板",
State: "active",
SortOrder: 10,
},
{
Key: "knowledge-operations",
Label: "知识运营专员",
Tier: "generic",
WorkerType: "dw",
Route: "/apps/knowledge-operations",
Summary: "对象字典、证据索引与规则同步",
WorkStatus: "4 个待整理",
RiskLabel: "1 个映射待确认",
Color: "#8e6cf2",
Stage: "证据整理",
Progress: 63,
MarketTag: "已安装",
Version: "v1.0",
ConnectorScope: "连接知识资产 / 文档系统",
PermissionScope: "知识资产读取、规则发布建议、证据索引维护",
ResourceBindings: "知识库、文档系统、规则字典、对象映射表",
InfoSources: "对象字典、规则文档、证据片段、变更记录",
BaseSkills: "对象映射、证据抽取、规则同步、发布检查",
AIAssistance: "自动识别字段冲突并给出映射建议",
GeneratedSkills: "把高频清洗与映射动作沉淀为新规则技能",
State: "active",
SortOrder: 20,
},
{
Key: "process-coordination",
Label: "流程推进专员",
Tier: "generic",
WorkerType: "adw",
Route: "/apps/process-coordination",
Summary: "跨部门催办、checkpoint 跟进与阻塞升级",
WorkStatus: "6 个待跟进",
RiskLabel: "2 个阻塞",
Color: "#14b8a6",
Stage: "阻塞清理",
Progress: 57,
MarketTag: "可升级",
Version: "v1.0",
ConnectorScope: "连接审批流 / 企微 / 项目事项",
PermissionScope: "事项读写、催办通知、阻塞升级、审批触发",
ResourceBindings: "审批流、项目事项、企微、日程提醒",
InfoSources: "任务看板、流程 checkpoint、阻塞日志、责任人状态",
BaseSkills: "催办升级、checkpoint 跟进、阻塞清理、责任路由",
AIAssistance: "自动判断升级路径并规划下一步协同动作",
GeneratedSkills: "把成功的跨部门推进套路沉淀为自动推进技能",
State: "active",
SortOrder: 30,
},
{
Key: "report-generation",
Label: "报告生成专员",
Tier: "generic",
WorkerType: "dw",
Route: "/apps/report-generation",
Summary: "日报、周报、复盘和交付说明自动生成",
WorkStatus: "3 个待输出",
RiskLabel: "0 个异常",
Color: "#ec4899",
Stage: "报告汇总",
Progress: 76,
MarketTag: "可升级",
Version: "v1.0",
ConnectorScope: "连接交付物 / 知识库 / 模板中心",
PermissionScope: "交付物读取、模板套用、报告产出",
ResourceBindings: "交付模板、知识库、任务结果、证据引用",
InfoSources: "日报数据、周进度、复盘纪要、交付清单",
BaseSkills: "日报生成、周报汇总、复盘摘要、交付说明编排",
AIAssistance: "自动抽取重点变化并生成适配受众的版本",
GeneratedSkills: "把高质量报告结构沉淀为新模板技能",
State: "active",
SortOrder: 40,
},
{
Key: "contract-review",
Label: "合同审查专员",
Tier: "industry",
WorkerType: "adw",
Route: "/apps/contract-review",
Summary: "华东客户主协议修订",
WorkStatus: "2 个待确认",
RiskLabel: "1 个高风险",
Color: "#f56c6c",
Stage: "人工复核",
Progress: 72,
MarketTag: "已安装",
Version: "v1.4",
ConnectorScope: "连接 DMS / OA / 合同模板库",
PermissionScope: "合同文本读取、红线建议、审批流提交、例外升级",
ResourceBindings: "DMS、OA、模板库、法务规则库",
InfoSources: "合同正文、历史条款、谈判纪要、法务规则",
BaseSkills: "条款抽取、风险比对、红线生成、例外说明",
AIAssistance: "自动分析条款冲突并规划复核重点",
GeneratedSkills: "从人工复核结果中提炼新条款规则与红线模式",
State: "active",
SortOrder: 50,
},
{
Key: "solution-proposal",
Label: "售前方案专员",
Tier: "industry",
WorkerType: "adw",
Route: "/apps/solution-proposal",
Summary: "A 客户智能培训升级方案",
WorkStatus: "3 个待办",
RiskLabel: "1 个依赖阻塞",
Color: "#409eff",
Stage: "方案生成",
Progress: 58,
MarketTag: "可升级",
Version: "v1.2",
ConnectorScope: "连接 CRM / OA / 知识库",
PermissionScope: "商机读取、方案草案生成、评审流发起、范围确认",
ResourceBindings: "CRM、OA、知识库、方案模板中心",
InfoSources: "商机信息、调研纪要、行业方案、交付案例",
BaseSkills: "需求澄清、方案草案、连接器范围规划、风险边界说明",
AIAssistance: "自动组合客户上下文并生成多版本方案草案",
GeneratedSkills: "把高转化方案结构沉淀为可复用售前技能",
State: "active",
SortOrder: 60,
},
{
Key: "logistics-fulfillment",
Label: "履约跟单专员",
Tier: "industry",
WorkerType: "adw",
Route: "/apps/logistics-fulfillment",
Summary: "美西航线本周履约看板",
WorkStatus: "1 个异常",
RiskLabel: "2 个节点延迟",
Color: "#e6a23c",
Stage: "异常处置",
Progress: 49,
MarketTag: "试用",
Version: "v0.9",
ConnectorScope: "连接船司 / 邮件 / 企微",
PermissionScope: "节点读取、异常通知、履约状态写回、升级协同",
ResourceBindings: "船司系统、邮件、企微、履约看板",
InfoSources: "节点状态、异常邮件、订舱资料、客户承诺时间",
BaseSkills: "节点跟踪、异常识别、催办升级、状态回写",
AIAssistance: "自动判断异常影响范围并生成处置建议",
GeneratedSkills: "把高频异常处理流程沉淀为自动履约技能",
State: "active",
SortOrder: 70,
},
{
Key: "hr-email-sorter",
Label: "HR 邮件整理专员",
Tier: "industry",
WorkerType: "dw",
Route: "/apps/hr-email-sorter",
Summary: "识别并整理招聘邮箱中的简历邮件",
WorkStatus: "3 封待处理",
RiskLabel: "0 个异常",
Color: "#409eff",
Stage: "邮件处理",
Progress: 0,
MarketTag: "试用",
Version: "v1.0",
ConnectorScope: "连接 HR 招聘邮箱 / 邮件系统",
PermissionScope: "招聘邮箱读取、候选人表写入",
ResourceBindings: "HR 招聘邮箱、候选人管理表",
InfoSources: "HR 招聘邮箱收件箱",
BaseSkills: "简历邮件识别、候选人信息抽取、去重归类",
AIAssistance: "自动识别简历邮件并抽取候选人字段",
GeneratedSkills: "从历史招聘中沉淀简历筛选规则",
State: "active",
SortOrder: 80,
},
{
Key: "resume-processor",
Label: "简历处理专员",
Tier: "industry",
WorkerType: "dw",
Route: "/apps/resume-processor",
Summary: "筛选候选人并安排面试",
WorkStatus: "5 份待筛选",
RiskLabel: "1 个匹配待确认",
Color: "#67c23a",
Stage: "简历筛选",
Progress: 0,
MarketTag: "试用",
Version: "v1.0",
ConnectorScope: "连接候选人表 / 岗位需求表",
PermissionScope: "候选人表读写、面试安排表写入、岗位需求表读取",
ResourceBindings: "候选人表、岗位需求表、面试安排表",
InfoSources: "候选人表、岗位需求表",
BaseSkills: "简历筛选、评分排序、跟进标记、面试安排",
AIAssistance: "自动匹配岗位要求并给出排序建议",
GeneratedSkills: "从录用决策中沉淀筛选模型",
State: "active",
SortOrder: 90,
},
}
for _, item := range items {
EnsureSpecialistStructuredRecords(&item)
var existing model.Specialist
if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil {
if err := DB.Create(&item).Error; err != nil {
return err
}
continue
}
updates := map[string]any{}
if existing.WorkerType == "" {
updates["worker_type"] = item.WorkerType
}
if existing.PermissionScope == "" {
updates["permission_scope"] = item.PermissionScope
}
if existing.ResourceBindings == "" {
updates["resource_bindings"] = item.ResourceBindings
}
if existing.InfoSources == "" {
updates["info_sources"] = item.InfoSources
}
if existing.BaseSkills == "" {
updates["base_skills"] = item.BaseSkills
}
if existing.AIAssistance == "" {
updates["ai_assistance"] = item.AIAssistance
}
if existing.GeneratedSkills == "" {
updates["generated_skills"] = item.GeneratedSkills
}
if existing.InputsRecordsJSON == "" {
updates["source_records_json"] = item.InputsRecordsJSON
}
if existing.PermissionRecordsJSON == "" {
updates["permission_records_json"] = item.PermissionRecordsJSON
}
if existing.ActionRecordsJSON == "" {
updates["action_records_json"] = item.ActionRecordsJSON
}
if existing.ResultRecordsJSON == "" {
updates["result_records_json"] = item.ResultRecordsJSON
}
if len(updates) > 0 {
if err := DB.Model(&existing).Updates(updates).Error; err != nil {
return err
}
}
}
log.Println("[OK] 专员目录种子已导入")
return nil
}
func ensureSeedMedia(adminID uint, approvedDir string, item mediaSeed, bindID *uint) error {
dst := filepath.Join(approvedDir, item.storedName)
if err := ensureMediaLink(item.sourcePath, dst); err != nil {
return err
}
query := DB.Where("stored_name = ? AND bind_type = ?", item.storedName, item.bindType)
if bindID == nil {
query = query.Where("bind_id IS NULL")
} else {
query = query.Where("bind_id = ?", *bindID)
}
var existing model.MediaFile
if err := query.First(&existing).Error; err == nil {
updates := map[string]any{
"filename": item.filename,
"stored_path": item.storedName,
"status": "approved",
"source": "admin",
"submitter_id": adminID,
"extracted": existing.FileExt != "pdf",
}
return DB.Model(&existing).Updates(updates).Error
}
info, err := os.Stat(item.sourcePath)
if err != nil {
return err
}
record := model.MediaFile{
Filename: item.filename,
StoredName: item.storedName,
StoredPath: item.storedName,
FileExt: strings.TrimPrefix(strings.ToLower(filepath.Ext(item.filename)), "."),
FileSize: info.Size(),
Status: "approved",
Source: "admin",
SubmitterID: adminID,
BindType: item.bindType,
BindID: bindID,
Extracted: strings.ToLower(filepath.Ext(item.filename)) != ".pdf",
}
return DB.Create(&record).Error
}
func ensureMediaLink(src, dst string) error {
absSrc, err := filepath.Abs(src)
if err != nil {
return err
}
if fi, err := os.Lstat(dst); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(dst)
if err == nil {
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(dst), target)
}
if absTarget, absErr := filepath.Abs(target); absErr == nil && absTarget == absSrc {
return nil
}
}
if err := os.Remove(dst); err != nil {
return err
}
} else if _, err := os.Stat(dst); err == nil {
return nil
} else if err := os.Remove(dst); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Symlink(absSrc, dst)
}
func firstExistingPath(paths ...string) string {
for _, p := range paths {
if p == "" {
continue
}
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
@@ -0,0 +1,271 @@
package store
import (
"encoding/json"
"strings"
"eaisalestrain/backend/internal/model"
)
// EnsureSpecialistStructuredRecords 补齐四类结构化记录,供工作区 UI 直接消费。
func EnsureSpecialistStructuredRecords(item *model.Specialist) {
if item == nil {
return
}
if strings.TrimSpace(item.InputsRecordsJSON) == "" {
item.InputsRecordsJSON = mustJSON(buildInputsRecords(item))
}
if strings.TrimSpace(item.PermissionRecordsJSON) == "" {
item.PermissionRecordsJSON = mustJSON(buildPermissionRecords(item))
}
if strings.TrimSpace(item.ActionRecordsJSON) == "" {
item.ActionRecordsJSON = mustJSON(buildActionRecords(item))
}
if strings.TrimSpace(item.ResultRecordsJSON) == "" {
item.ResultRecordsJSON = mustJSON(buildResultRecords(item))
}
}
func ValidateStructuredRecordsJSON(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
var arr []map[string]any
return json.Unmarshal([]byte(value), &arr) == nil
}
func buildInputsRecords(item *model.Specialist) []map[string]any {
records := make([]map[string]any, 0)
for _, value := range splitText(item.ConnectorScope) {
records = append(records, map[string]any{
"input_name": value,
"input_type": "connector",
"connector_name": value,
"object_type": "system_scope",
"object_id": item.Key,
"fetched_at": "",
"last_sync_status": "ready",
"confidence": 1,
"citation": "来自连接范围定义",
})
}
for _, value := range splitText(item.InfoSources) {
records = append(records, map[string]any{
"input_name": value,
"input_type": "input",
"connector_name": "workspace_context",
"object_type": "context_input",
"object_id": item.Key,
"fetched_at": "",
"last_sync_status": "ready",
"confidence": 0.92,
"citation": "来自输入定义",
})
}
return records
}
func buildPermissionRecords(item *model.Specialist) []map[string]any {
approvalReason := "关键动作需要人工确认"
forbidden := []string{"自动发布", "批量删除", "跨系统迁移"}
if item.WorkerType == "adw" {
approvalReason = "中高风险动作进入审批或人工复核"
forbidden = []string{"无审批自动发布", "无审批跨系统写回", "高风险批量改写"}
}
return []map[string]any{
{
"title": "资源范围",
"resource_scope": item.PermissionScope,
"access_mode": "mixed",
"approval_required": false,
"approval_role": []string{},
"approval_reason": "",
"forbidden_actions": []string{},
"delegation_allowed": item.WorkerType == "adw",
},
{
"title": "访问边界",
"resource_scope": firstNonEmpty(item.ResourceBindings, item.ConnectorScope),
"access_mode": "read_write_scoped",
"approval_required": false,
"approval_role": []string{},
"approval_reason": "",
"forbidden_actions": []string{},
"delegation_allowed": item.WorkerType == "adw",
},
{
"title": "审批要求",
"resource_scope": item.PermissionScope,
"access_mode": "approval_guarded",
"approval_required": true,
"approval_role": []string{"管理员", "业务负责人"},
"approval_reason": approvalReason,
"forbidden_actions": []string{},
"delegation_allowed": item.WorkerType == "adw",
},
{
"title": "禁止动作",
"resource_scope": "",
"access_mode": "deny",
"approval_required": false,
"approval_role": []string{},
"approval_reason": "",
"forbidden_actions": forbidden,
"delegation_allowed": false,
},
}
}
func buildActionRecords(item *model.Specialist) []map[string]any {
records := make([]map[string]any, 0)
skills := splitText(item.BaseSkills)
if len(skills) == 0 {
skills = []string{"事项处理"}
}
outputs := splitText(item.GeneratedSkills)
infoSources := splitText(item.InfoSources)
for i, value := range skills {
records = append(records, map[string]any{
"action_name": value,
"action_type": inferActionType(value),
"trigger_mode": triggerMode(item.WorkerType),
"input_sources": infoSources,
"expected_output": outputs,
"risk_level": inferRiskLevel(value),
"status": defaultActionStatus(i),
"approval_state": approvalState(item.WorkerType, value),
"operator": strings.ToUpper(firstNonEmpty(item.WorkerType, "dw")),
"started_at": "",
"finished_at": "",
})
}
return records
}
func buildResultRecords(item *model.Specialist) []map[string]any {
results := make([]map[string]any, 0)
derived := splitText(item.BaseSkills)
for _, value := range splitText(item.GeneratedSkills) {
results = append(results, map[string]any{
"result_type": "skill",
"result_title": value,
"result_status": statusByMarketTag(item.MarketTag),
"derived_from_actions": derived,
"derived_from_sources": splitText(item.InfoSources),
"artifact_url": "",
"published_to": []string{"工作区"},
"confirmed_by": "",
"confirmed_at": "",
"version": firstNonEmpty(item.Version, "v1.0"),
})
}
if len(results) == 0 {
results = append(results, map[string]any{
"result_type": "summary",
"result_title": firstNonEmpty(item.WorkStatus, item.Summary, item.Label+"结果"),
"result_status": statusByMarketTag(item.MarketTag),
"derived_from_actions": derived,
"derived_from_sources": splitText(item.InfoSources),
"artifact_url": "",
"published_to": []string{"工作区"},
"confirmed_by": "",
"confirmed_at": "",
"version": firstNonEmpty(item.Version, "v1.0"),
})
}
return results
}
func mustJSON(v any) string {
data, err := json.Marshal(v)
if err != nil {
return "[]"
}
return string(data)
}
func splitText(value string) []string {
parts := strings.FieldsFunc(value, func(r rune) bool {
switch r {
case '、', ',', ',', '/', '|', ';', ';', '\n':
return true
default:
return false
}
})
items := make([]string, 0, len(parts))
for _, item := range parts {
item = strings.TrimSpace(item)
if item != "" {
items = append(items, item)
}
}
return items
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func inferActionType(value string) string {
switch {
case strings.Contains(value, "生成"), strings.Contains(value, "输出"), strings.Contains(value, "编排"):
return "generation"
case strings.Contains(value, "抽取"), strings.Contains(value, "识别"), strings.Contains(value, "比对"):
return "analysis"
case strings.Contains(value, "催办"), strings.Contains(value, "跟进"), strings.Contains(value, "升级"):
return "coordination"
default:
return "execution"
}
}
func triggerMode(workerType string) string {
if workerType == "adw" {
return "auto_assisted"
}
return "manual_assisted"
}
func inferRiskLevel(value string) string {
switch {
case strings.Contains(value, "升级"), strings.Contains(value, "写回"), strings.Contains(value, "审批"):
return "medium"
case strings.Contains(value, "删除"), strings.Contains(value, "发布"):
return "high"
default:
return "low"
}
}
func defaultActionStatus(index int) string {
if index == 0 {
return "ready"
}
return "pending"
}
func approvalState(workerType, value string) string {
if workerType == "adw" && (strings.Contains(value, "升级") || strings.Contains(value, "审批") || strings.Contains(value, "写回")) {
return "approval_required"
}
return "not_required"
}
func statusByMarketTag(tag string) string {
switch tag {
case "可升级":
return "ready_for_upgrade"
case "试用":
return "trial"
default:
return "active"
}
}
@@ -0,0 +1,37 @@
package web
import "net/http"
// AppError 业务错误(对齐 Python core/errors.py 的错误码与状态码)
type AppError struct {
StatusCode int
Code string
Message string
}
func (e *AppError) Error() string { return e.Message }
func NewBadRequest(msg string) *AppError {
return &AppError{http.StatusBadRequest, "bad_request", msg}
}
func NewAuthError(msg string) *AppError {
return &AppError{http.StatusUnauthorized, "unauthorized", msg}
}
func NewForbiddenError(msg string) *AppError {
return &AppError{http.StatusForbidden, "forbidden", msg}
}
func NewNotFoundError(msg string) *AppError {
return &AppError{http.StatusNotFound, "not_found", msg}
}
func NewConflictError(msg string) *AppError {
return &AppError{http.StatusConflict, "conflict", msg}
}
func NewLLMNotConfigured(msg string) *AppError {
return &AppError{http.StatusNotImplemented, "llm_not_configured", msg}
}
func NewLLMError(msg string) *AppError {
return &AppError{http.StatusBadGateway, "llm_request_failed", msg}
}
func NewPointsExhausted(msg string) *AppError {
return &AppError{http.StatusPaymentRequired, "ai_points_exhausted", msg}
}
@@ -0,0 +1,17 @@
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
// OK 成功响应信封 {"data": ..., "error": null, "message": "success"}
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"data": data, "error": nil, "message": "success"})
}
// Fail 错误响应信封 {"data": null, "error": ..., "message": ...}
func Fail(c *gin.Context, e *AppError) {
c.JSON(e.StatusCode, gin.H{"data": nil, "error": e.Code, "message": e.Message})
}