init: 数字员工平台初始代码
包含前端(Vue3 + VueFlow 画布)、后端(Go)、文档体系。 - 工作台画布:节点拖放、连线模式、右键菜单、AI 助手 - 后端:连接器 API、专员种子数据 - 导航:左侧导航、工坊、市场、控制台
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
var allowedExt = map[string]bool{
|
||||
"ppt": true, "pptx": true, "pdf": true, "doc": true, "docx": true,
|
||||
"mp4": true, "png": true, "jpg": true, "jpeg": true,
|
||||
}
|
||||
|
||||
var validBindType = map[string]bool{"company": true, "product": true, "course": true, "none": true}
|
||||
|
||||
const defaultChunkSize = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
// randomID 生成随机十六进制标识(文件名/上传会话)
|
||||
func randomID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func extOf(filename string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
}
|
||||
|
||||
func sizeLimitFor(ext string) int64 {
|
||||
if ext == "mp4" {
|
||||
return Cfg.FileMaxVideo
|
||||
}
|
||||
return Cfg.FileMaxDoc
|
||||
}
|
||||
|
||||
// 审批前置:未审批文件存 pending/、驳回文件存 rejected/ 子目录(均不对外公开),
|
||||
// 审批通过后移动到 approved/ 子目录(静态公开,仅 approved 可访问)。
|
||||
func approvedDir() string { return filepath.Join(Cfg.KBDataDir, "approved") }
|
||||
func pendingDir() string { return filepath.Join(Cfg.KBDataDir, "pending") }
|
||||
func rejectedDir() string { return filepath.Join(Cfg.KBDataDir, "rejected") }
|
||||
|
||||
// mediaPathFor 按状态返回素材物理路径
|
||||
func mediaPathFor(m model.MediaFile) string {
|
||||
switch m.Status {
|
||||
case "approved":
|
||||
return filepath.Join(approvedDir(), m.StoredName)
|
||||
case "rejected":
|
||||
return filepath.Join(rejectedDir(), m.StoredName)
|
||||
default:
|
||||
return filepath.Join(pendingDir(), m.StoredName)
|
||||
}
|
||||
}
|
||||
|
||||
func parseBind(c *gin.Context) (string, *uint) {
|
||||
bt := c.PostForm("bind_type")
|
||||
if bt == "" {
|
||||
bt = "none"
|
||||
}
|
||||
if !validBindType[bt] {
|
||||
bt = "none"
|
||||
}
|
||||
var bid *uint
|
||||
if s := c.PostForm("bind_id"); s != "" {
|
||||
if n, err := strconv.ParseUint(s, 10, 64); err == nil && n > 0 {
|
||||
v := uint(n)
|
||||
bid = &v
|
||||
}
|
||||
}
|
||||
return bt, bid
|
||||
}
|
||||
|
||||
func sourceOf(c *gin.Context) string {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u != nil && u.Role == "admin" {
|
||||
return "admin"
|
||||
}
|
||||
return "employee"
|
||||
}
|
||||
|
||||
// ============ 直传 ============
|
||||
|
||||
// Upload POST /api/media/upload —— 文档/小视频直传
|
||||
func Upload(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("缺少文件字段 file"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
ext := extOf(header.Filename)
|
||||
if !allowedExt[ext] {
|
||||
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
|
||||
return
|
||||
}
|
||||
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
|
||||
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
|
||||
return
|
||||
}
|
||||
if header.Size > sizeLimitFor(ext) {
|
||||
web.Fail(c, web.NewBadRequest("文件超过大小限制"))
|
||||
return
|
||||
}
|
||||
|
||||
bindType, bindID := parseBind(c)
|
||||
source := sourceOf(c)
|
||||
status := "pending"
|
||||
targetDir := pendingDir()
|
||||
if source == "admin" {
|
||||
status = "approved" // 管理员上传自动通过
|
||||
targetDir = approvedDir()
|
||||
}
|
||||
|
||||
storedName := randomID() + "." + ext
|
||||
dst := filepath.Join(targetDir, storedName)
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建存储目录失败"))
|
||||
return
|
||||
}
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("保存文件失败"))
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
out.Close()
|
||||
web.Fail(c, web.NewBadRequest("写入文件失败"))
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
|
||||
m := model.MediaFile{
|
||||
Filename: header.Filename,
|
||||
StoredName: storedName,
|
||||
StoredPath: storedName,
|
||||
FileExt: ext,
|
||||
FileSize: header.Size,
|
||||
Status: status,
|
||||
Source: source,
|
||||
SubmitterID: u.ID,
|
||||
BindType: bindType,
|
||||
BindID: bindID,
|
||||
}
|
||||
if err := store.DB.Create(&m).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
|
||||
return
|
||||
}
|
||||
if status == "approved" {
|
||||
go runExtractPipeline(m.ID)
|
||||
}
|
||||
web.OK(c, gin.H{"media_id": m.ID, "status": m.Status, "filename": m.Filename})
|
||||
}
|
||||
|
||||
// ============ 分片上传 ============
|
||||
|
||||
type uploadSession struct {
|
||||
Filename string
|
||||
FileSize int64
|
||||
Ext string
|
||||
BindType string
|
||||
BindID *uint
|
||||
ChunkSize int64
|
||||
ChunkCount int
|
||||
Chunks map[int]bool
|
||||
}
|
||||
|
||||
var uploadSessions = struct {
|
||||
sync.RWMutex
|
||||
m map[string]*uploadSession
|
||||
}{m: map[string]*uploadSession{}}
|
||||
|
||||
// UploadInit POST /api/media/upload-init —— 初始化分片上传
|
||||
func UploadInit(c *gin.Context) {
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BindType string `json:"bind_type"`
|
||||
BindID *uint `json:"bind_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Filename == "" || req.FileSize <= 0 {
|
||||
web.Fail(c, web.NewBadRequest("filename/file_size 必填"))
|
||||
return
|
||||
}
|
||||
ext := extOf(req.Filename)
|
||||
if !allowedExt[ext] {
|
||||
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
|
||||
return
|
||||
}
|
||||
if req.FileSize > sizeLimitFor(ext) {
|
||||
web.Fail(c, web.NewBadRequest("文件超过大小限制"))
|
||||
return
|
||||
}
|
||||
if req.BindType == "" || !validBindType[req.BindType] {
|
||||
req.BindType = "none"
|
||||
}
|
||||
|
||||
id := randomID()
|
||||
chunkCount := int((req.FileSize + defaultChunkSize - 1) / defaultChunkSize)
|
||||
sess := &uploadSession{
|
||||
Filename: req.Filename, FileSize: req.FileSize, Ext: ext,
|
||||
BindType: req.BindType, BindID: req.BindID,
|
||||
ChunkSize: defaultChunkSize, ChunkCount: chunkCount, Chunks: map[int]bool{},
|
||||
}
|
||||
uploadSessions.Lock()
|
||||
uploadSessions.m[id] = sess
|
||||
uploadSessions.Unlock()
|
||||
|
||||
web.OK(c, gin.H{"upload_id": id, "chunk_size": defaultChunkSize, "chunk_count": chunkCount})
|
||||
}
|
||||
|
||||
// UploadChunk POST /api/media/upload-chunk —— 上传分片
|
||||
func UploadChunk(c *gin.Context) {
|
||||
id := c.PostForm("upload_id")
|
||||
idx, err := strconv.Atoi(c.PostForm("chunk_index"))
|
||||
if err != nil || id == "" {
|
||||
web.Fail(c, web.NewBadRequest("upload_id/chunk_index 必填"))
|
||||
return
|
||||
}
|
||||
uploadSessions.RLock()
|
||||
sess := uploadSessions.m[id]
|
||||
uploadSessions.RUnlock()
|
||||
if sess == nil {
|
||||
web.Fail(c, web.NewNotFoundError("上传会话不存在"))
|
||||
return
|
||||
}
|
||||
if idx < 0 || idx >= sess.ChunkCount {
|
||||
web.Fail(c, web.NewBadRequest("chunk_index 越界"))
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("缺少分片文件"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tmpDir := filepath.Join(pendingDir(), "tmp", id)
|
||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建临时目录失败"))
|
||||
return
|
||||
}
|
||||
dst := filepath.Join(tmpDir, fmt.Sprintf("%06d", idx))
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("保存分片失败"))
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
out.Close()
|
||||
web.Fail(c, web.NewBadRequest("写入分片失败"))
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
|
||||
uploadSessions.Lock()
|
||||
sess.Chunks[idx] = true
|
||||
uploadSessions.Unlock()
|
||||
web.OK(c, gin.H{"upload_id": id, "chunk_index": idx, "received": len(sess.Chunks)})
|
||||
}
|
||||
|
||||
// UploadComplete POST /api/media/upload-complete —— 合并分片
|
||||
func UploadComplete(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
var req struct {
|
||||
UploadID string `json:"upload_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UploadID == "" {
|
||||
web.Fail(c, web.NewBadRequest("upload_id 必填"))
|
||||
return
|
||||
}
|
||||
uploadSessions.RLock()
|
||||
sess := uploadSessions.m[req.UploadID]
|
||||
uploadSessions.RUnlock()
|
||||
if sess == nil {
|
||||
web.Fail(c, web.NewNotFoundError("上传会话不存在"))
|
||||
return
|
||||
}
|
||||
if len(sess.Chunks) != sess.ChunkCount {
|
||||
web.Fail(c, web.NewBadRequest(fmt.Sprintf("分片不完整:%d/%d", len(sess.Chunks), sess.ChunkCount)))
|
||||
return
|
||||
}
|
||||
|
||||
source := sourceOf(c)
|
||||
status := "pending"
|
||||
targetDir := pendingDir()
|
||||
if source == "admin" {
|
||||
status = "approved" // 管理员上传自动通过
|
||||
targetDir = approvedDir()
|
||||
}
|
||||
|
||||
storedName := randomID() + "." + sess.Ext
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建存储目录失败"))
|
||||
return
|
||||
}
|
||||
final := filepath.Join(targetDir, storedName)
|
||||
out, err := os.Create(final)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建最终文件失败"))
|
||||
return
|
||||
}
|
||||
tmpDir := filepath.Join(pendingDir(), "tmp", req.UploadID)
|
||||
for i := 0; i < sess.ChunkCount; i++ {
|
||||
part := filepath.Join(tmpDir, fmt.Sprintf("%06d", i))
|
||||
f, err := os.Open(part)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
web.Fail(c, web.NewBadRequest("读取分片失败"))
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(out, f); err != nil {
|
||||
f.Close()
|
||||
out.Close()
|
||||
web.Fail(c, web.NewBadRequest("合并分片失败"))
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
out.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
uploadSessions.Lock()
|
||||
delete(uploadSessions.m, req.UploadID)
|
||||
uploadSessions.Unlock()
|
||||
|
||||
m := model.MediaFile{
|
||||
Filename: sess.Filename, StoredName: storedName, StoredPath: storedName,
|
||||
FileExt: sess.Ext, FileSize: sess.FileSize, Status: status,
|
||||
Source: source, SubmitterID: u.ID, BindType: sess.BindType, BindID: sess.BindID,
|
||||
}
|
||||
if err := store.DB.Create(&m).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
|
||||
return
|
||||
}
|
||||
if status == "approved" {
|
||||
go runExtractPipeline(m.ID)
|
||||
}
|
||||
web.OK(c, gin.H{"media_id": m.ID, "status": m.Status, "filename": m.Filename})
|
||||
}
|
||||
|
||||
// ============ 预览 / 状态 ============
|
||||
|
||||
// Preview GET /api/media/preview/{mediaId} —— 仅 approved 可预览
|
||||
func Preview(c *gin.Context) {
|
||||
id, ok := parseID(c, "mediaId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var m model.MediaFile
|
||||
if err := store.DB.First(&m, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("素材不存在"))
|
||||
return
|
||||
}
|
||||
if m.Status != "approved" {
|
||||
web.Fail(c, web.NewForbiddenError("素材未通过审批,不可预览"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{
|
||||
"preview_url": "/media/" + m.StoredName,
|
||||
"file_ext": m.FileExt,
|
||||
"can_preview": true,
|
||||
})
|
||||
}
|
||||
|
||||
// MediaStatus GET /api/media/status/{mediaId}
|
||||
func MediaStatus(c *gin.Context) {
|
||||
id, ok := parseID(c, "mediaId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var m model.MediaFile
|
||||
if err := store.DB.First(&m, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("素材不存在"))
|
||||
return
|
||||
}
|
||||
var chunkCount int64
|
||||
store.DB.Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", m.ID).Count(&chunkCount)
|
||||
web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount})
|
||||
}
|
||||
|
||||
// ============ 审批 ============
|
||||
|
||||
// AuditList GET /api/media/audit-list?status=&page=&size=
|
||||
func AuditList(c *gin.Context) {
|
||||
q := store.DB.Model(&model.MediaFile{})
|
||||
if s := c.Query("status"); s != "" {
|
||||
q = q.Where("status = ?", s)
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var items []model.MediaFile
|
||||
q.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items)
|
||||
web.OK(c, gin.H{"total": total, "items": items})
|
||||
}
|
||||
|
||||
// AuditMedia POST /api/media/audit/{mediaId} —— 审批(通过→触发异步提取)
|
||||
func AuditMedia(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
id, ok := parseID(c, "mediaId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var m model.MediaFile
|
||||
if err := store.DB.First(&m, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("素材不存在"))
|
||||
return
|
||||
}
|
||||
if m.Status != "pending" {
|
||||
web.Fail(c, web.NewConflictError("该素材已审批,不可重复操作"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
auditBy := u.ID
|
||||
switch req.Action {
|
||||
case "approve":
|
||||
// 审批前置:通过后才把文件从 pending/ 移动到 approved/(公开)
|
||||
src := filepath.Join(pendingDir(), m.StoredName)
|
||||
dst := filepath.Join(approvedDir(), m.StoredName)
|
||||
if err := os.MkdirAll(approvedDir(), 0o755); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建公开目录失败"))
|
||||
return
|
||||
}
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("移动文件失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
m.Status = "approved"
|
||||
m.AuditBy = &auditBy
|
||||
m.AuditAt = &now
|
||||
m.RejectReason = ""
|
||||
case "reject":
|
||||
if strings.TrimSpace(req.RejectReason) == "" {
|
||||
web.Fail(c, web.NewBadRequest("驳回理由必填"))
|
||||
return
|
||||
}
|
||||
// 驳回:把文件从 pending/ 移到 rejected/,落实「审批前置」物理隔离
|
||||
src := filepath.Join(pendingDir(), m.StoredName)
|
||||
dst := filepath.Join(rejectedDir(), m.StoredName)
|
||||
if err := os.MkdirAll(rejectedDir(), 0o755); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建驳回目录失败"))
|
||||
return
|
||||
}
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("移动文件失败:"+err.Error()))
|
||||
return
|
||||
}
|
||||
m.Status = "rejected"
|
||||
m.RejectReason = req.RejectReason
|
||||
m.AuditBy = &auditBy
|
||||
m.AuditAt = &now
|
||||
default:
|
||||
web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Save(&m).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("审批失败"))
|
||||
return
|
||||
}
|
||||
if m.Status == "approved" {
|
||||
go runExtractPipeline(m.ID)
|
||||
}
|
||||
web.OK(c, gin.H{"id": m.ID, "status": m.Status})
|
||||
}
|
||||
|
||||
// ============ 异步文档提取管线 ============
|
||||
|
||||
// runExtractPipeline 审批通过后:文档转 PDF → pdftotext → 切片入库
|
||||
func runExtractPipeline(mediaID uint) {
|
||||
var m model.MediaFile
|
||||
if err := store.DB.First(&m, mediaID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
docExts := map[string]bool{"ppt": true, "pptx": true, "doc": true, "docx": true}
|
||||
var text string
|
||||
var err error
|
||||
src := mediaPathFor(m)
|
||||
|
||||
switch {
|
||||
case docExts[m.FileExt]:
|
||||
text, err = libreofficeExtract(src)
|
||||
case m.FileExt == "pdf":
|
||||
text, err = pdftotextExtract(src)
|
||||
default:
|
||||
// 视频/图片:仅标记,不提取
|
||||
store.DB.Model(&m).Update("extracted", true)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[提取失败] media_id=%d: %v", mediaID, err)
|
||||
return
|
||||
}
|
||||
|
||||
chunks := splitIntoChunks(text, 1000)
|
||||
for i, chunk := range chunks {
|
||||
if strings.TrimSpace(chunk) == "" {
|
||||
continue
|
||||
}
|
||||
store.DB.Create(&model.KnowledgeChunk{
|
||||
MediaFileID: &m.ID,
|
||||
SourceType: m.FileExt,
|
||||
SourceID: strconv.FormatUint(uint64(m.ID), 10),
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
})
|
||||
}
|
||||
store.DB.Model(&m).Update("extracted", true)
|
||||
log.Printf("[提取完成] media_id=%d chunks=%d", mediaID, len(chunks))
|
||||
}
|
||||
|
||||
func libreofficeExtract(src string) (string, error) {
|
||||
tmp, err := os.MkdirTemp("", "lo-convert")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
// 独立 UserInstallation:systemd 加固(ProtectHome=true)下 HOME 不可写,
|
||||
// 避免 LibreOffice profile 初始化失败。
|
||||
profileURI := "file://" + filepath.Join(tmp, "profile")
|
||||
cmd := exec.Command(Cfg.LibreOfficeBin, "-env:UserInstallation="+profileURI,
|
||||
"--headless", "--convert-to", "pdf", "--outdir", tmp, src)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("libreoffice 转换失败: %v %s", err, out)
|
||||
}
|
||||
base := strings.TrimSuffix(filepath.Base(src), filepath.Ext(src)) + ".pdf"
|
||||
return pdftotextExtract(filepath.Join(tmp, base))
|
||||
}
|
||||
|
||||
func pdftotextExtract(pdf string) (string, error) {
|
||||
cmd := exec.Command(Cfg.PdftotextBin, "-enc", "UTF-8", pdf, "-")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pdftotext 提取失败: %v", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func splitIntoChunks(text string, maxChars int) []string {
|
||||
paragraphs := strings.Split(text, "\n\n")
|
||||
var chunks []string
|
||||
current := ""
|
||||
for _, p := range paragraphs {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if len(current)+len(p) > maxChars {
|
||||
if current != "" {
|
||||
chunks = append(chunks, current)
|
||||
}
|
||||
current = p
|
||||
} else if current == "" {
|
||||
current = p
|
||||
} else {
|
||||
current += "\n\n" + p
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
chunks = append(chunks, current)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
var validNoteItemTypes = map[string]bool{"company": true, "product": true, "course": true}
|
||||
|
||||
// ListNotes GET /api/notes?item_type=&item_id= —— 我的学习笔记
|
||||
func ListNotes(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
q := store.DB.Where("user_id = ?", u.ID)
|
||||
if t := c.Query("item_type"); t != "" {
|
||||
q = q.Where("item_type = ?", t)
|
||||
}
|
||||
if s := c.Query("item_id"); s != "" {
|
||||
if n, err := strconv.ParseUint(s, 10, 64); err == nil {
|
||||
q = q.Where("item_id = ?", n)
|
||||
}
|
||||
}
|
||||
var items []model.StudyNote
|
||||
if err := q.Order("updated_at DESC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询笔记失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// CreateNote POST /api/notes —— 新增学习笔记
|
||||
func CreateNote(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ItemType string `json:"item_type"`
|
||||
ItemID uint `json:"item_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if !validNoteItemTypes[req.ItemType] {
|
||||
web.Fail(c, web.NewBadRequest("item_type 必须为 company/product/course"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Content) == "" {
|
||||
web.Fail(c, web.NewBadRequest("笔记内容不能为空"))
|
||||
return
|
||||
}
|
||||
n := model.StudyNote{UserID: u.ID, ItemType: req.ItemType, ItemID: req.ItemID, Content: strings.TrimSpace(req.Content)}
|
||||
if err := store.DB.Create(&n).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("保存笔记失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, n)
|
||||
}
|
||||
|
||||
// UpdateNote PUT /api/notes/{id} —— 编辑学习笔记
|
||||
func UpdateNote(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var n model.StudyNote
|
||||
if err := store.DB.First(&n, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("笔记不存在"))
|
||||
return
|
||||
}
|
||||
if n.UserID != u.ID {
|
||||
web.Fail(c, web.NewForbiddenError("无权操作他人笔记"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Content) == "" {
|
||||
web.Fail(c, web.NewBadRequest("笔记内容不能为空"))
|
||||
return
|
||||
}
|
||||
n.Content = strings.TrimSpace(req.Content)
|
||||
if err := store.DB.Save(&n).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新笔记失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, n)
|
||||
}
|
||||
|
||||
// DeleteNote DELETE /api/notes/{id} —— 删除学习笔记
|
||||
func DeleteNote(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var n model.StudyNote
|
||||
if err := store.DB.First(&n, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("笔记不存在"))
|
||||
return
|
||||
}
|
||||
if n.UserID != u.ID {
|
||||
web.Fail(c, web.NewForbiddenError("无权操作他人笔记"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&n).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除笔记失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// notifyUser 给单个用户发站内通知(userID=0 忽略)。
|
||||
func notifyUser(userID uint, ntype, title, content, link string) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
store.DB.Create(&model.Notification{
|
||||
UserID: userID, Type: ntype, Title: title, Content: content, Link: link,
|
||||
})
|
||||
}
|
||||
|
||||
// notifyAllEmployees 给所有启用员工发通知(如新正式考试发布)。
|
||||
func notifyAllEmployees(ntype, title, content, link string) {
|
||||
var users []model.User
|
||||
store.DB.Where("role = ? AND status = ?", "employee", "active").Select("id").Find(&users)
|
||||
for _, u := range users {
|
||||
notifyUser(u.ID, ntype, title, content, link)
|
||||
}
|
||||
}
|
||||
|
||||
// MyNotifications GET /api/notifications?unread_only=true —— 我的通知列表
|
||||
func MyNotifications(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
q := store.DB.Model(&model.Notification{}).Where("user_id = ?", u.ID)
|
||||
if c.Query("unread_only") == "true" {
|
||||
q = q.Where("read = ?", false)
|
||||
}
|
||||
var items []model.Notification
|
||||
q.Order("created_at DESC, id DESC").Limit(100).Find(&items)
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// UnreadNotificationCount GET /api/notifications/unread-count —— 未读数(铃铛角标)
|
||||
func UnreadNotificationCount(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
var n int64
|
||||
store.DB.Model(&model.Notification{}).Where("user_id = ? AND read = ?", u.ID, false).Count(&n)
|
||||
web.OK(c, gin.H{"unread": n})
|
||||
}
|
||||
|
||||
// MarkNotificationRead PUT /api/notifications/{id}/read —— 标记单条已读
|
||||
func MarkNotificationRead(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var n model.Notification
|
||||
if err := store.DB.First(&n, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("通知不存在"))
|
||||
return
|
||||
}
|
||||
if n.UserID != u.ID {
|
||||
web.Fail(c, web.NewForbiddenError("无权操作他人通知"))
|
||||
return
|
||||
}
|
||||
store.DB.Model(&n).Update("read", true)
|
||||
web.OK(c, gin.H{"id": n.ID, "read": true})
|
||||
}
|
||||
|
||||
// MarkAllNotificationsRead PUT /api/notifications/read-all —— 全部标记已读
|
||||
func MarkAllNotificationsRead(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
store.DB.Model(&model.Notification{}).Where("user_id = ? AND read = ?", u.ID, false).Update("read", true)
|
||||
web.OK(c, gin.H{"read": true})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// —— 积分规则(游戏化成长值,集中一处便于调整,见 docs/changelog.md 待确认项)——
|
||||
const (
|
||||
ptFirstCompany = 10 // 首次浏览公司介绍
|
||||
ptFirstProduct = 2 // 首次浏览单个产品
|
||||
ptFirstCourse = 5 // 首次浏览单门课程
|
||||
ptSelfTestSubmit = 5 // 自测提交一次
|
||||
ptFormalPass = 50 // 正式考试通过
|
||||
ptMistakeResolved = 3 // 错题标记已掌握
|
||||
)
|
||||
|
||||
// awardPoints 记录积分流水并累加到 user.learning_points(points<=0 直接忽略,避免负分刷分)。
|
||||
func awardPoints(userID uint, eventType string, points int, refType string, refID uint) {
|
||||
if userID == 0 || points <= 0 {
|
||||
return
|
||||
}
|
||||
store.DB.Create(&model.PointEvent{
|
||||
UserID: userID,
|
||||
EventType: eventType,
|
||||
Points: points,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
})
|
||||
store.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
UpdateColumn("learning_points", gorm.Expr("learning_points + ?", points))
|
||||
}
|
||||
|
||||
// awardFirstView 首次浏览公司/产品/课程加分(由 RecordLearningProgress 幂等触发)。
|
||||
func awardFirstView(userID uint, itemType string, itemID uint) {
|
||||
switch itemType {
|
||||
case "company":
|
||||
awardPoints(userID, "first_company", ptFirstCompany, itemType, itemID)
|
||||
case "product":
|
||||
awardPoints(userID, "first_product", ptFirstProduct, itemType, itemID)
|
||||
case "course":
|
||||
awardPoints(userID, "first_course", ptFirstCourse, itemType, itemID)
|
||||
}
|
||||
}
|
||||
|
||||
// MyPoints GET /api/points/me —— 我的积分 + 最近积分流水
|
||||
func MyPoints(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
var total int
|
||||
store.DB.Raw(`SELECT COALESCE(SUM(points), 0) FROM point_event WHERE user_id = ?`, u.ID).Scan(&total)
|
||||
var events []model.PointEvent
|
||||
store.DB.Where("user_id = ?", u.ID).Order("created_at DESC, id DESC").Limit(50).Find(&events)
|
||||
web.OK(c, gin.H{"total": total, "events": events})
|
||||
}
|
||||
|
||||
// Leaderboard GET /api/points/leaderboard —— 学习积分排行榜(全员,含我的名次)
|
||||
func Leaderboard(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
var users []model.User
|
||||
store.DB.Where("role = ? AND status = ?", "employee", "active").
|
||||
Order("learning_points DESC, id ASC").Find(&users)
|
||||
|
||||
out := make([]gin.H, 0, len(users))
|
||||
myRank := 0
|
||||
for i, usr := range users {
|
||||
out = append(out, gin.H{
|
||||
"rank": i + 1,
|
||||
"user_id": usr.ID,
|
||||
"full_name": usr.FullName,
|
||||
"department": usr.Department,
|
||||
"learning_points": usr.LearningPoints,
|
||||
"is_me": u != nil && usr.ID == u.ID,
|
||||
})
|
||||
if u != nil && usr.ID == u.ID {
|
||||
myRank = i + 1
|
||||
}
|
||||
}
|
||||
web.OK(c, gin.H{"items": out, "my_rank": myRank, "total": len(out), "updated_at": time.Now()})
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// ============ 岗位 CRUD(管理员) ============
|
||||
|
||||
// ListPositions GET /api/positions?status= —— 岗位列表
|
||||
func ListPositions(c *gin.Context) {
|
||||
q := store.DB.Model(&model.Position{})
|
||||
switch st := c.Query("status"); st {
|
||||
case "": // 默认仅 active
|
||||
q = q.Where("status = ?", "active")
|
||||
case "all": // 管理员维护全量
|
||||
default:
|
||||
q = q.Where("status = ?", st)
|
||||
}
|
||||
var items []model.Position
|
||||
if err := q.Order("id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询岗位失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// CreatePosition POST /api/positions (admin)
|
||||
func CreatePosition(c *gin.Context) {
|
||||
var p model.Position
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if p.Code == "" || p.Name == "" {
|
||||
web.Fail(c, web.NewBadRequest("编号、名称为必填"))
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
store.DB.Model(&model.Position{}).Where("code = ?", p.Code).Count(&count)
|
||||
if count > 0 {
|
||||
web.Fail(c, web.NewConflictError("岗位编号已存在"))
|
||||
return
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = "active"
|
||||
}
|
||||
if err := store.DB.Create(&p).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建岗位失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, p)
|
||||
}
|
||||
|
||||
// UpdatePosition PUT /api/positions/{id} (admin)
|
||||
func UpdatePosition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.Position
|
||||
if err := store.DB.First(&p, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("岗位不存在"))
|
||||
return
|
||||
}
|
||||
var req model.Position
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
web.Fail(c, web.NewBadRequest("名称为必填"))
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "active"
|
||||
}
|
||||
if req.Code != "" && req.Code != p.Code {
|
||||
var count int64
|
||||
store.DB.Model(&model.Position{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
|
||||
if count > 0 {
|
||||
web.Fail(c, web.NewConflictError("岗位编号已存在"))
|
||||
return
|
||||
}
|
||||
p.Code = req.Code
|
||||
}
|
||||
p.Name = req.Name
|
||||
p.Description = req.Description
|
||||
p.Status = req.Status
|
||||
if err := store.DB.Save(&p).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新岗位失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, p)
|
||||
}
|
||||
|
||||
// DeletePosition DELETE /api/positions/{id} (admin) —— 软删除(status=inactive)
|
||||
func DeletePosition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.Position
|
||||
if err := store.DB.First(&p, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("岗位不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("停用岗位失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id, "status": "inactive"})
|
||||
}
|
||||
|
||||
// ============ 岗位知识映射(管理员) ============
|
||||
|
||||
// positionKnowledgeReq 映射条目入参(与 model.PositionKnowledge 同构)
|
||||
type positionKnowledgeReq struct {
|
||||
Domain string `json:"domain"`
|
||||
CourseID *uint `json:"course_id"`
|
||||
ProductID *uint `json:"product_id"`
|
||||
RequiredLevel string `json:"required_level"`
|
||||
Weight float64 `json:"weight"`
|
||||
IsMandatory bool `json:"is_mandatory"`
|
||||
}
|
||||
|
||||
// ListPositionKnowledge GET /api/positions/{id}/knowledge —— 某岗位知识映射列表
|
||||
func ListPositionKnowledge(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var items []model.PositionKnowledge
|
||||
if err := store.DB.Where("position_id = ?", id).Order("id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询岗位知识映射失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// SavePositionKnowledge PUT /api/positions/{id}/knowledge —— 保存映射(整表覆盖)
|
||||
func SavePositionKnowledge(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var pos model.Position
|
||||
if err := store.DB.First(&pos, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("岗位不存在"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Items []positionKnowledgeReq `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
// 校验并规整映射条目
|
||||
rows := make([]model.PositionKnowledge, 0, len(req.Items))
|
||||
for _, it := range req.Items {
|
||||
if !validDomains[it.Domain] {
|
||||
web.Fail(c, web.NewBadRequest("知识域 domain 非法(仅 company/product/sales)"))
|
||||
return
|
||||
}
|
||||
if it.RequiredLevel == "" {
|
||||
it.RequiredLevel = model.LevelL1
|
||||
}
|
||||
if !model.ValidLevels[it.RequiredLevel] {
|
||||
web.Fail(c, web.NewBadRequest("级别 required_level 非法(仅 L1/L2/L3/L4)"))
|
||||
return
|
||||
}
|
||||
if it.Weight <= 0 {
|
||||
it.Weight = 1
|
||||
}
|
||||
rows = append(rows, model.PositionKnowledge{
|
||||
PositionID: id,
|
||||
Domain: it.Domain,
|
||||
CourseID: it.CourseID,
|
||||
ProductID: it.ProductID,
|
||||
RequiredLevel: it.RequiredLevel,
|
||||
Weight: it.Weight,
|
||||
IsMandatory: it.IsMandatory,
|
||||
})
|
||||
}
|
||||
// 整表覆盖:先删旧,再批量插入
|
||||
if err := store.DB.Where("position_id = ?", id).Delete(&model.PositionKnowledge{}).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("清除旧映射失败"))
|
||||
return
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err := store.DB.Create(&rows).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("保存岗位知识映射失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
var out []model.PositionKnowledge
|
||||
store.DB.Where("position_id = ?", id).Order("id ASC").Find(&out)
|
||||
web.OK(c, out)
|
||||
}
|
||||
|
||||
// ============ 用户设岗(管理员) ============
|
||||
|
||||
// SetUserPosition PUT /api/users/{id}/position —— {position_id: 5 | null}
|
||||
func SetUserPosition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var u model.User
|
||||
if err := store.DB.First(&u, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("用户不存在"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
PositionID *uint `json:"position_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
posName := ""
|
||||
if req.PositionID != nil {
|
||||
var pos model.Position
|
||||
if err := store.DB.First(&pos, *req.PositionID).Error; err != nil || pos.Status != "active" {
|
||||
web.Fail(c, web.NewBadRequest("岗位不存在或已停用"))
|
||||
return
|
||||
}
|
||||
posName = pos.Name
|
||||
}
|
||||
u.PositionID = req.PositionID
|
||||
if err := store.DB.Save(&u).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("设置用户岗位失败"))
|
||||
return
|
||||
}
|
||||
if req.PositionID != nil {
|
||||
notifyUser(u.ID, "position_set", "岗位已设置",
|
||||
fmt.Sprintf("你的岗位已设置为「%s」,可在「我的岗位清单」查看应学内容", posName),
|
||||
"/exam/my-position")
|
||||
}
|
||||
web.OK(c, gin.H{"id": u.ID, "position_id": u.PositionID})
|
||||
}
|
||||
|
||||
// ============ 学员:我的岗位应学清单 ============
|
||||
|
||||
// MyPosition GET /api/my/position —— 我的岗位 + 应学范围(课程/产品/域)
|
||||
func MyPosition(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil || u.PositionID == nil {
|
||||
web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0})
|
||||
return
|
||||
}
|
||||
var pos model.Position
|
||||
if err := store.DB.First(&pos, *u.PositionID).Error; err != nil || pos.Status != "active" {
|
||||
web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0})
|
||||
return
|
||||
}
|
||||
var pks []model.PositionKnowledge
|
||||
store.DB.Where("position_id = ?", pos.ID).Order("id ASC").Find(&pks)
|
||||
|
||||
// 批量解析课程/产品名称
|
||||
courseIDs := make([]uint, 0, len(pks))
|
||||
productIDs := make([]uint, 0, len(pks))
|
||||
for _, pk := range pks {
|
||||
if pk.CourseID != nil {
|
||||
courseIDs = append(courseIDs, *pk.CourseID)
|
||||
}
|
||||
if pk.ProductID != nil {
|
||||
productIDs = append(productIDs, *pk.ProductID)
|
||||
}
|
||||
}
|
||||
courseName := map[uint]string{}
|
||||
if len(courseIDs) > 0 {
|
||||
var courses []model.Course
|
||||
store.DB.Where("id IN ?", courseIDs).Find(&courses)
|
||||
for _, c := range courses {
|
||||
courseName[c.ID] = c.Name
|
||||
}
|
||||
}
|
||||
productName := map[uint]string{}
|
||||
if len(productIDs) > 0 {
|
||||
var products []model.Product
|
||||
store.DB.Where("id IN ?", productIDs).Find(&products)
|
||||
for _, p := range products {
|
||||
productName[p.ID] = p.Name
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(pks))
|
||||
for _, pk := range pks {
|
||||
list = append(list, gin.H{
|
||||
"id": pk.ID,
|
||||
"domain": pk.Domain,
|
||||
"course_id": pk.CourseID,
|
||||
"product_id": pk.ProductID,
|
||||
"course_name": nameOrDash(courseName, pk.CourseID),
|
||||
"product_name": nameOrDash(productName, pk.ProductID),
|
||||
"required_level": pk.RequiredLevel,
|
||||
"weight": pk.Weight,
|
||||
"is_mandatory": pk.IsMandatory,
|
||||
})
|
||||
}
|
||||
web.OK(c, gin.H{
|
||||
"position": pos,
|
||||
"knowledge": list,
|
||||
"count": len(list),
|
||||
})
|
||||
}
|
||||
|
||||
func nameOrDash(m map[uint]string, id *uint) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
if n, ok := m[*id]; ok {
|
||||
return n
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ============ 岗位考试蓝图(管理员) ============
|
||||
|
||||
type blueprintReq struct {
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ListPositionBlueprint GET /api/positions/{id}/blueprint —— 某岗位考试蓝图列表
|
||||
func ListPositionBlueprint(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var items []model.PositionExamBlueprint
|
||||
if err := store.DB.Where("position_id = ?", id).Order("id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询岗位考试蓝图失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// SavePositionBlueprint PUT /api/positions/{id}/blueprint —— 保存蓝图(整表覆盖)
|
||||
func SavePositionBlueprint(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var pos model.Position
|
||||
if err := store.DB.First(&pos, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("岗位不存在"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Items []blueprintReq `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
rows := make([]model.PositionExamBlueprint, 0, len(req.Items))
|
||||
for _, it := range req.Items {
|
||||
if !validQTypes[it.Type] {
|
||||
web.Fail(c, web.NewBadRequest("题型 type 非法(仅 single/multiple/judge/essay)"))
|
||||
return
|
||||
}
|
||||
if it.Domain != "" && !validDomains[it.Domain] {
|
||||
web.Fail(c, web.NewBadRequest("知识域 domain 非法(仅 company/product/sales,或留空不限)"))
|
||||
return
|
||||
}
|
||||
if it.Count <= 0 {
|
||||
web.Fail(c, web.NewBadRequest("蓝图各条 count 必须大于 0"))
|
||||
return
|
||||
}
|
||||
rows = append(rows, model.PositionExamBlueprint{
|
||||
PositionID: id, Domain: it.Domain, Type: it.Type, Count: it.Count,
|
||||
})
|
||||
}
|
||||
if err := store.DB.Where("position_id = ?", id).Delete(&model.PositionExamBlueprint{}).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("清除旧蓝图失败"))
|
||||
return
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err := store.DB.Create(&rows).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("保存岗位考试蓝图失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
var out []model.PositionExamBlueprint
|
||||
store.DB.Where("position_id = ?", id).Order("id ASC").Find(&out)
|
||||
web.OK(c, out)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// ListProducts GET /api/products?category=&status=
|
||||
func ListProducts(c *gin.Context) {
|
||||
q := store.DB.Model(&model.Product{})
|
||||
if cat := c.Query("category"); cat != "" {
|
||||
q = q.Where("category = ?", cat)
|
||||
}
|
||||
switch st := c.Query("status"); st {
|
||||
case "": // 默认仅 active(员工浏览)
|
||||
q = q.Where("status = ?", "active")
|
||||
case "all": // 管理员维护全量
|
||||
default:
|
||||
q = q.Where("status = ?", st)
|
||||
}
|
||||
var items []model.Product
|
||||
if err := q.Order("id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询产品失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// GetProduct GET /api/products/{id}
|
||||
func GetProduct(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.Product
|
||||
if err := store.DB.First(&p, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("产品不存在"))
|
||||
return
|
||||
}
|
||||
web.OK(c, p)
|
||||
}
|
||||
|
||||
// CreateProduct POST /api/products (admin)
|
||||
func CreateProduct(c *gin.Context) {
|
||||
var p model.Product
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if p.Code == "" || p.Name == "" || p.Category == "" {
|
||||
web.Fail(c, web.NewBadRequest("编号、名称、分类为必填"))
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
store.DB.Model(&model.Product{}).Where("code = ?", p.Code).Count(&count)
|
||||
if count > 0 {
|
||||
web.Fail(c, web.NewConflictError("产品编号已存在"))
|
||||
return
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = "active"
|
||||
}
|
||||
if err := store.DB.Create(&p).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建产品失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, p)
|
||||
}
|
||||
|
||||
// UpdateProduct PUT /api/products/{id} (admin)
|
||||
func UpdateProduct(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.Product
|
||||
if err := store.DB.First(&p, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("产品不存在"))
|
||||
return
|
||||
}
|
||||
var req model.Product
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Category == "" {
|
||||
web.Fail(c, web.NewBadRequest("名称、分类为必填"))
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "active"
|
||||
}
|
||||
|
||||
if req.Code != "" && req.Code != p.Code {
|
||||
var count int64
|
||||
store.DB.Model(&model.Product{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
|
||||
if count > 0 {
|
||||
web.Fail(c, web.NewConflictError("产品编号已存在"))
|
||||
return
|
||||
}
|
||||
p.Code = req.Code
|
||||
}
|
||||
p.Name = req.Name
|
||||
p.Category = req.Category
|
||||
p.Tags = req.Tags
|
||||
p.Description = req.Description
|
||||
p.Pricing = req.Pricing
|
||||
p.CommissionRecommend = req.CommissionRecommend
|
||||
p.CommissionNegotiate = req.CommissionNegotiate
|
||||
p.PublicCourseBonus = req.PublicCourseBonus
|
||||
p.VersionRisk = req.VersionRisk
|
||||
p.ReportRules = req.ReportRules
|
||||
p.Status = req.Status
|
||||
|
||||
if err := store.DB.Save(&p).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新产品失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, p)
|
||||
}
|
||||
|
||||
// DeleteProduct DELETE /api/products/{id} (admin) —— 软删除(status=inactive)
|
||||
func DeleteProduct(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.Product
|
||||
if err := store.DB.First(&p, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("产品不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("停用产品失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id, "status": "inactive"})
|
||||
}
|
||||
|
||||
// ImportProducts POST /api/products/import (admin) —— 按 code 幂等 upsert
|
||||
func ImportProducts(c *gin.Context) {
|
||||
var items []model.Product
|
||||
if err := c.ShouldBindJSON(&items); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
web.Fail(c, web.NewBadRequest("导入数据为空"))
|
||||
return
|
||||
}
|
||||
created, updated := 0, 0
|
||||
for _, it := range items {
|
||||
if it.Code == "" || it.Name == "" || it.Category == "" {
|
||||
continue
|
||||
}
|
||||
if it.Status == "" {
|
||||
it.Status = "active"
|
||||
}
|
||||
var existing model.Product
|
||||
err := store.DB.Where("code = ?", it.Code).First(&existing).Error
|
||||
if err != nil {
|
||||
if store.DB.Create(&it).Error == nil {
|
||||
created++
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 合并:仅覆盖导入数据中非零的字段,避免零值抹除已有数据
|
||||
if it.Name != "" {
|
||||
existing.Name = it.Name
|
||||
}
|
||||
if it.Category != "" {
|
||||
existing.Category = it.Category
|
||||
}
|
||||
if it.Tags != "" {
|
||||
existing.Tags = it.Tags
|
||||
}
|
||||
if it.Description != "" {
|
||||
existing.Description = it.Description
|
||||
}
|
||||
if it.Pricing != "" {
|
||||
existing.Pricing = it.Pricing
|
||||
}
|
||||
if it.CommissionRecommend != "" {
|
||||
existing.CommissionRecommend = it.CommissionRecommend
|
||||
}
|
||||
if it.CommissionNegotiate != "" {
|
||||
existing.CommissionNegotiate = it.CommissionNegotiate
|
||||
}
|
||||
if it.PublicCourseBonus != "" {
|
||||
existing.PublicCourseBonus = it.PublicCourseBonus
|
||||
}
|
||||
if it.VersionRisk != "" {
|
||||
existing.VersionRisk = it.VersionRisk
|
||||
}
|
||||
if it.ReportRules != "" {
|
||||
existing.ReportRules = it.ReportRules
|
||||
}
|
||||
existing.Status = it.Status
|
||||
if store.DB.Save(&existing).Error == nil {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
web.OK(c, gin.H{"created": created, "updated": updated, "total": len(items)})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// domainLabels 能力雷达维度(正式考试按域聚合的掌握度)。
|
||||
var domainLabels = map[string]string{
|
||||
"company": "公司认知",
|
||||
"product": "产品知识",
|
||||
"sales": "销售能力",
|
||||
}
|
||||
|
||||
// domainOrder 雷达图固定排序。
|
||||
var domainOrder = []string{"company", "product", "sales"}
|
||||
|
||||
// detailWrap 正式考试答题明细(与 ExamSubmit 持久化的 detail_json 结构对齐)。
|
||||
type detailWrap struct {
|
||||
Questions []struct {
|
||||
QuestionID uint `json:"question_id"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
} `json:"questions"`
|
||||
}
|
||||
|
||||
// MyProfile GET /api/my/profile —— 学员学习档案:能力雷达 + 学习统计 + 成绩趋势 + 积分。
|
||||
func MyProfile(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
|
||||
// 学习进度(公司/产品/课程浏览)
|
||||
var lps []model.LearningProgress
|
||||
store.DB.Where("user_id = ?", u.ID).Find(&lps)
|
||||
companyViewed := false
|
||||
productViewed, courseViewed := 0, 0
|
||||
for _, lp := range lps {
|
||||
switch lp.ItemType {
|
||||
case "company":
|
||||
companyViewed = true
|
||||
case "product":
|
||||
productViewed++
|
||||
case "course":
|
||||
courseViewed++
|
||||
}
|
||||
}
|
||||
|
||||
// 错题统计
|
||||
var mistakeTotal, mistakeResolved int64
|
||||
store.DB.Model(&model.MistakeRecord{}).Where("user_id = ?", u.ID).Count(&mistakeTotal)
|
||||
store.DB.Model(&model.MistakeRecord{}).Where("user_id = ? AND resolved = ?", u.ID, true).Count(&mistakeResolved)
|
||||
|
||||
// 自测次数(自测不落 exam_record,改由积分流水统计)
|
||||
var selfTestCount int64
|
||||
store.DB.Model(&model.PointEvent{}).Where("user_id = ? AND event_type = ?", u.ID, "self_test").Count(&selfTestCount)
|
||||
|
||||
// 正式考试记录 + 按域能力聚合
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Where("user_id = ?", u.ID).Order("submitted_at ASC").Find(&recs)
|
||||
|
||||
// 题目 → 域映射(用于从答题明细反推各域掌握度)
|
||||
var questions []model.Question
|
||||
store.DB.Select("id", "domain").Find(&questions)
|
||||
qDomain := map[uint]string{}
|
||||
for _, q := range questions {
|
||||
qDomain[q.ID] = q.Domain
|
||||
}
|
||||
|
||||
type domainAgg struct {
|
||||
Correct int `json:"correct"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
perDomain := map[string]*domainAgg{}
|
||||
formalPassed := 0
|
||||
trend := make([]gin.H, 0, len(recs))
|
||||
for _, r := range recs {
|
||||
if r.Passed {
|
||||
formalPassed++
|
||||
}
|
||||
trend = append(trend, gin.H{
|
||||
"exam_name": r.ExamName,
|
||||
"score": r.Score,
|
||||
"total_score": r.TotalScore,
|
||||
"passed": r.Passed,
|
||||
"submitted_at": r.SubmittedAt,
|
||||
})
|
||||
|
||||
var dw detailWrap
|
||||
if err := json.Unmarshal([]byte(r.DetailJSON), &dw); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, qd := range dw.Questions {
|
||||
domain := qDomain[qd.QuestionID]
|
||||
if domain == "" {
|
||||
continue
|
||||
}
|
||||
a := perDomain[domain]
|
||||
if a == nil {
|
||||
a = &domainAgg{}
|
||||
perDomain[domain] = a
|
||||
}
|
||||
a.Total++
|
||||
if qd.IsCorrect {
|
||||
a.Correct++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
domains := make([]gin.H, 0, len(domainOrder))
|
||||
for _, d := range domainOrder {
|
||||
a := perDomain[d]
|
||||
if a == nil {
|
||||
a = &domainAgg{}
|
||||
}
|
||||
mastery := 0.0
|
||||
if a.Total > 0 {
|
||||
mastery = round1(float64(a.Correct) * 100 / float64(a.Total))
|
||||
}
|
||||
domains = append(domains, gin.H{
|
||||
"domain": d,
|
||||
"label": domainLabels[d],
|
||||
"correct": a.Correct,
|
||||
"total": a.Total,
|
||||
"mastery": mastery,
|
||||
})
|
||||
}
|
||||
|
||||
// 薄弱点:有作答记录中掌握度最低的域
|
||||
weakDomain := ""
|
||||
weakMastery := 100.0
|
||||
for _, d := range domains {
|
||||
if d["total"].(int) > 0 && d["mastery"].(float64) < weakMastery {
|
||||
weakMastery = d["mastery"].(float64)
|
||||
weakDomain = d["domain"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"learning_points": u.LearningPoints,
|
||||
"stats": gin.H{
|
||||
"company_viewed": companyViewed,
|
||||
"product_viewed": productViewed,
|
||||
"course_viewed": courseViewed,
|
||||
"self_test_count": selfTestCount,
|
||||
"formal_count": len(recs),
|
||||
"formal_passed": formalPassed,
|
||||
"mistake_count": mistakeTotal,
|
||||
"mistake_resolved": mistakeResolved,
|
||||
},
|
||||
"domains": domains,
|
||||
"weak_domain": weakDomain,
|
||||
"recent_scores": trend,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/config"
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
)
|
||||
|
||||
// Cfg 全局配置,RegisterRoutes 时注入
|
||||
var Cfg *config.Config
|
||||
|
||||
// RegisterRoutes 注册所有路由
|
||||
func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
Cfg = cfg
|
||||
|
||||
// 素材静态预览(只读):仅公开 approved/ 子目录(已审批通过文件),
|
||||
// pending/ 未审批文件不对外暴露,落实「审批前置」。
|
||||
if err := os.MkdirAll(filepath.Join(cfg.KBDataDir, "approved"), 0o755); err == nil {
|
||||
r.Static("/media", filepath.Join(cfg.KBDataDir, "approved"))
|
||||
}
|
||||
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
ver := "1.1.0"
|
||||
if p, err := config.LoadPlatformConfig(); err == nil {
|
||||
ver = p.Version
|
||||
}
|
||||
c.JSON(200, gin.H{"status": "ok", "service": "eaisalestrain-app", "version": ver})
|
||||
})
|
||||
|
||||
auth := r.Group("/api/auth")
|
||||
{
|
||||
auth.POST("/login", Login)
|
||||
auth.GET("/me", middleware.Auth(cfg), Me)
|
||||
}
|
||||
|
||||
// 员工可读
|
||||
r.GET("/api/company-train", middleware.Auth(cfg), GetCompanyTrain)
|
||||
r.POST("/api/company-train/suggest-material", middleware.Auth(cfg), SuggestMaterial)
|
||||
r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists)
|
||||
r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), GetSpecialistByKey)
|
||||
r.GET("/api/specialists/summary", middleware.Auth(cfg), SpecialistSummary)
|
||||
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
|
||||
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
|
||||
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
|
||||
r.GET("/api/products", middleware.Auth(cfg), ListProducts)
|
||||
r.GET("/api/products/:id", middleware.Auth(cfg), GetProduct)
|
||||
r.GET("/api/courses", middleware.Auth(cfg), ListCourses)
|
||||
r.GET("/api/courses/:id", middleware.Auth(cfg), GetCourse)
|
||||
|
||||
// 学员端考试
|
||||
r.GET("/api/exam/list", middleware.Auth(cfg), ExamList)
|
||||
r.GET("/api/exam/cover", middleware.Auth(cfg), ExamCover)
|
||||
r.POST("/api/exam/start", middleware.Auth(cfg), ExamStart)
|
||||
r.POST("/api/exam/submit", middleware.Auth(cfg), ExamSubmit)
|
||||
r.GET("/api/exam/record", middleware.Auth(cfg), ExamRecordList)
|
||||
r.GET("/api/exam/record/:recordId", middleware.Auth(cfg), ExamRecordDetail)
|
||||
r.GET("/api/exam/mistakes", middleware.Auth(cfg), MyMistakes)
|
||||
r.PUT("/api/exam/mistakes/:id/resolve", middleware.Auth(cfg), ResolveMistake)
|
||||
r.POST("/api/exam/mistakes/practice", middleware.Auth(cfg), MistakePractice)
|
||||
r.GET("/api/exam/certificates", middleware.Auth(cfg), MyCertificates)
|
||||
r.GET("/api/exam/certificates/:id", middleware.Auth(cfg), CertificateDetail)
|
||||
|
||||
// 学员:我的岗位应学清单 / 学习档案 / 积分与排行榜
|
||||
r.GET("/api/my/position", middleware.Auth(cfg), MyPosition)
|
||||
r.GET("/api/my/profile", middleware.Auth(cfg), MyProfile)
|
||||
r.GET("/api/points/me", middleware.Auth(cfg), MyPoints)
|
||||
r.GET("/api/points/leaderboard", middleware.Auth(cfg), Leaderboard)
|
||||
|
||||
// 站内消息通知
|
||||
r.GET("/api/notifications", middleware.Auth(cfg), MyNotifications)
|
||||
r.GET("/api/notifications/unread-count", middleware.Auth(cfg), UnreadNotificationCount)
|
||||
r.PUT("/api/notifications/:id/read", middleware.Auth(cfg), MarkNotificationRead)
|
||||
r.PUT("/api/notifications/read-all", middleware.Auth(cfg), MarkAllNotificationsRead)
|
||||
|
||||
// 素材媒体(员工/管理员均可上传、预览)
|
||||
r.POST("/api/media/upload", middleware.Auth(cfg), Upload)
|
||||
r.POST("/api/media/upload-init", middleware.Auth(cfg), UploadInit)
|
||||
r.POST("/api/media/upload-chunk", middleware.Auth(cfg), UploadChunk)
|
||||
r.POST("/api/media/upload-complete", middleware.Auth(cfg), UploadComplete)
|
||||
r.GET("/api/media/preview/:mediaId", middleware.Auth(cfg), Preview)
|
||||
r.GET("/api/media/status/:mediaId", middleware.Auth(cfg), MediaStatus)
|
||||
|
||||
r.GET("/api/knowledge/status/:sourceId", middleware.Auth(cfg), KnowledgeStatus)
|
||||
|
||||
// ── AI 对话(普通员工可访问,管理员访问)──
|
||||
r.POST("/api/ai-chat/message", middleware.Auth(cfg), ChatMessage)
|
||||
r.GET("/api/ai-chat/quick-actions", middleware.Auth(cfg), QuickActions)
|
||||
r.POST("/api/ai-chat/quick-action", middleware.Auth(cfg), QuickAction)
|
||||
r.GET("/api/ai/me", middleware.Auth(cfg), AIUsageMe)
|
||||
r.GET("/api/ai/usage", middleware.Auth(cfg), AIUsage)
|
||||
r.GET("/api/ai/routes/chat", ListChatRoutes)
|
||||
r.GET("/api/ai/routes/embed", ListEmbedRoutes)
|
||||
|
||||
// 学习进度(员工上报 + 本人查询)
|
||||
r.POST("/api/learning/progress", middleware.Auth(cfg), RecordLearningProgress)
|
||||
r.GET("/api/learning/me", middleware.Auth(cfg), MyLearningProgress)
|
||||
|
||||
// 学习笔记(员工个人工具)
|
||||
r.GET("/api/notes", middleware.Auth(cfg), ListNotes)
|
||||
r.POST("/api/notes", middleware.Auth(cfg), CreateNote)
|
||||
r.PUT("/api/notes/:id", middleware.Auth(cfg), UpdateNote)
|
||||
r.DELETE("/api/notes/:id", middleware.Auth(cfg), DeleteNote)
|
||||
|
||||
// 管理员维护
|
||||
admin := r.Group("/api")
|
||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||
{
|
||||
admin.POST("/products", CreateProduct)
|
||||
admin.PUT("/products/:id", UpdateProduct)
|
||||
admin.DELETE("/products/:id", DeleteProduct)
|
||||
admin.POST("/products/import", ImportProducts)
|
||||
|
||||
admin.POST("/courses", CreateCourse)
|
||||
admin.PUT("/courses/:id", UpdateCourse)
|
||||
|
||||
admin.GET("/exam/questions", ListQuestions)
|
||||
admin.POST("/exam/questions", CreateQuestion)
|
||||
admin.PUT("/exam/questions/:id", UpdateQuestion)
|
||||
admin.DELETE("/exam/questions/:id", DeleteQuestion)
|
||||
admin.GET("/exam/papers", ListPapers)
|
||||
admin.POST("/exam/papers", CreatePaper)
|
||||
admin.PUT("/exam/papers/:id", UpdatePaper)
|
||||
admin.DELETE("/exam/papers/:id", DeletePaper)
|
||||
|
||||
admin.GET("/media/audit-list", AuditList)
|
||||
admin.POST("/media/audit/:mediaId", AuditMedia)
|
||||
|
||||
admin.POST("/knowledge/scan", KnowledgeScan)
|
||||
admin.GET("/knowledge/audit-list", KnowledgeAuditList)
|
||||
admin.POST("/knowledge/audit/:sourceId", KnowledgeAudit)
|
||||
admin.GET("/knowledge/export", ExportKnowledge)
|
||||
|
||||
admin.GET("/positions", ListPositions)
|
||||
admin.POST("/positions", CreatePosition)
|
||||
admin.PUT("/positions/:id", UpdatePosition)
|
||||
admin.DELETE("/positions/:id", DeletePosition)
|
||||
admin.POST("/specialists", CreateSpecialist)
|
||||
admin.PUT("/specialists/:id", UpdateSpecialist)
|
||||
admin.DELETE("/specialists/:id", DeleteSpecialist)
|
||||
admin.GET("/positions/:id/knowledge", ListPositionKnowledge)
|
||||
admin.PUT("/positions/:id/knowledge", SavePositionKnowledge)
|
||||
admin.GET("/positions/:id/blueprint", ListPositionBlueprint)
|
||||
admin.PUT("/positions/:id/blueprint", SavePositionBlueprint)
|
||||
admin.PUT("/users/:id/position", SetUserPosition)
|
||||
|
||||
admin.GET("/departments", ListDepartments)
|
||||
admin.POST("/departments", CreateDepartment)
|
||||
admin.PUT("/departments/:id", UpdateDepartment)
|
||||
admin.DELETE("/departments/:id", DeleteDepartment)
|
||||
|
||||
admin.GET("/system/users", ListUsers)
|
||||
admin.POST("/system/users", CreateUser)
|
||||
admin.PUT("/system/users/:id", UpdateUser)
|
||||
admin.GET("/system/users/:id/overview", UserOverview)
|
||||
admin.GET("/system/exam-records", ListExamRecords)
|
||||
admin.GET("/system/exam-records/export", ExportExamRecords)
|
||||
admin.GET("/system/exam-records/:id", GetExamRecord)
|
||||
admin.DELETE("/system/exam-records/:id", DeleteExamRecord)
|
||||
admin.GET("/system/dashboard", AdminDashboard)
|
||||
admin.GET("/system/exam-stats", ExamStats)
|
||||
admin.GET("/system/exam-stats-by-position", PositionExamStats)
|
||||
admin.GET("/system/department-stats", DepartmentStats)
|
||||
admin.GET("/system/certificates", AdminCertificates)
|
||||
admin.GET("/system/exam-coverage", ExamCoverage)
|
||||
admin.GET("/system/learning-progress", AdminLearningProgress)
|
||||
admin.GET("/system/config", GetConfig)
|
||||
admin.PUT("/system/config", UpdateConfig)
|
||||
|
||||
admin.GET("/ai/config", GetAIConfig)
|
||||
admin.PUT("/ai/config", PutAIConfig)
|
||||
admin.POST("/ai/reload", ReloadAIConfig)
|
||||
admin.GET("/ai/secrets-status", SecretsStatusHandler)
|
||||
admin.GET("/ai/usage/users", AIUsageUsers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"eaisalestrain/backend/internal/config"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// routeItem 路由摘要(返回给前端选择器)
|
||||
type routeItem struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func toRouteItems(routes []*config.RouteConfig) []routeItem {
|
||||
items := make([]routeItem, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
items = append(items, routeItem{
|
||||
ID: r.RouteID,
|
||||
Provider: r.Provider,
|
||||
Model: r.Model,
|
||||
BaseURL: r.BaseURL,
|
||||
Description: r.Description,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ListChatRoutes 返回可用 chat 路由列表(供前端选择器使用)
|
||||
func ListChatRoutes(c *gin.Context) {
|
||||
routes, err := config.GetRoutesByCategory("chat")
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewLLMNotConfigured("路由加载失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"routes": toRouteItems(routes)})
|
||||
}
|
||||
|
||||
// ListEmbedRoutes 返回可用 embedding 路由列表
|
||||
func ListEmbedRoutes(c *gin.Context) {
|
||||
routes, err := config.GetRoutesByCategory("embed")
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewLLMNotConfigured("路由加载失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"routes": toRouteItems(routes)})
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// ListSpecialists GET /api/specialists?tier=&state=&market_tag=
|
||||
func ListSpecialists(c *gin.Context) {
|
||||
q := store.DB.Model(&model.Specialist{})
|
||||
if tier := c.Query("tier"); tier != "" {
|
||||
q = q.Where("tier = ?", tier)
|
||||
}
|
||||
if marketTag := c.Query("market_tag"); marketTag != "" {
|
||||
q = q.Where("market_tag = ?", marketTag)
|
||||
}
|
||||
user := middleware.CurrentUser(c)
|
||||
isAdmin := user != nil && user.Role == "admin"
|
||||
switch state := c.Query("state"); state {
|
||||
case "":
|
||||
q = q.Where("state = ?", "active")
|
||||
case "all":
|
||||
if !isAdmin {
|
||||
q = q.Where("state = ?", "active")
|
||||
}
|
||||
default:
|
||||
q = q.Where("state = ?", state)
|
||||
}
|
||||
|
||||
var items []model.Specialist
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询专员目录失败"))
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
store.EnsureSpecialistStructuredRecords(&items[i])
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// GetSpecialistByKey GET /api/specialists/by-key/:key
|
||||
func GetSpecialistByKey(c *gin.Context) {
|
||||
key := strings.TrimSpace(c.Param("key"))
|
||||
if key == "" {
|
||||
web.Fail(c, web.NewBadRequest("专员 key 不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
q := store.DB.Model(&model.Specialist{}).Where("key = ?", key)
|
||||
user := middleware.CurrentUser(c)
|
||||
isAdmin := user != nil && user.Role == "admin"
|
||||
if !isAdmin {
|
||||
q = q.Where("state = ?", "active")
|
||||
}
|
||||
|
||||
var item model.Specialist
|
||||
if err := q.First(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("专员不存在"))
|
||||
return
|
||||
}
|
||||
store.EnsureSpecialistStructuredRecords(&item)
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
// SpecialistSummary GET /api/specialists/summary
|
||||
func SpecialistSummary(c *gin.Context) {
|
||||
type summary struct {
|
||||
Total int64 `json:"total"`
|
||||
Generic int64 `json:"generic"`
|
||||
Industry int64 `json:"industry"`
|
||||
DW int64 `json:"dw"`
|
||||
ADW int64 `json:"adw"`
|
||||
Upgradeable int64 `json:"upgradeable"`
|
||||
Trial int64 `json:"trial"`
|
||||
}
|
||||
|
||||
var resp summary
|
||||
count := func(column string, value string) int64 {
|
||||
var total int64
|
||||
q := store.DB.Model(&model.Specialist{}).Where("state = ?", "active")
|
||||
if column != "" {
|
||||
q = q.Where(column+" = ?", value)
|
||||
}
|
||||
q.Count(&total)
|
||||
return total
|
||||
}
|
||||
|
||||
resp.Total = count("", "")
|
||||
resp.Generic = count("tier", "generic")
|
||||
resp.Industry = count("tier", "industry")
|
||||
resp.DW = count("worker_type", "dw")
|
||||
resp.ADW = count("worker_type", "adw")
|
||||
resp.Upgradeable = count("market_tag", "可升级")
|
||||
resp.Trial = count("market_tag", "试用")
|
||||
|
||||
web.OK(c, resp)
|
||||
}
|
||||
|
||||
type specialistReq struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Tier string `json:"tier"`
|
||||
WorkerType string `json:"worker_type"`
|
||||
Route string `json:"route"`
|
||||
Summary string `json:"summary"`
|
||||
WorkStatus string `json:"work_status"`
|
||||
RiskLabel string `json:"risk_label"`
|
||||
Color string `json:"color"`
|
||||
Stage string `json:"stage"`
|
||||
Progress int `json:"progress"`
|
||||
MarketTag string `json:"market_tag"`
|
||||
Version string `json:"version"`
|
||||
ConnectorScope string `json:"connector_scope"`
|
||||
PermissionScope string `json:"permission_scope"`
|
||||
ResourceBindings string `json:"resource_bindings"`
|
||||
InfoSources string `json:"info_sources"`
|
||||
BaseSkills string `json:"base_skills"`
|
||||
AIAssistance string `json:"ai_assistance"`
|
||||
GeneratedSkills string `json:"generated_skills"`
|
||||
InputsRecordsJSON string `json:"inputs_records_json"`
|
||||
LegacySourceRecordsJSON string `json:"source_records_json"`
|
||||
PermissionRecordsJSON string `json:"permission_records_json"`
|
||||
ActionRecordsJSON string `json:"action_records_json"`
|
||||
ResultRecordsJSON string `json:"result_records_json"`
|
||||
State string `json:"state"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func normalizeSpecialistReq(req *specialistReq) {
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
req.Label = strings.TrimSpace(req.Label)
|
||||
req.Tier = strings.TrimSpace(req.Tier)
|
||||
req.WorkerType = strings.TrimSpace(req.WorkerType)
|
||||
req.Route = strings.TrimSpace(req.Route)
|
||||
req.Summary = strings.TrimSpace(req.Summary)
|
||||
req.WorkStatus = strings.TrimSpace(req.WorkStatus)
|
||||
req.RiskLabel = strings.TrimSpace(req.RiskLabel)
|
||||
req.Color = strings.TrimSpace(req.Color)
|
||||
req.Stage = strings.TrimSpace(req.Stage)
|
||||
req.MarketTag = strings.TrimSpace(req.MarketTag)
|
||||
req.Version = strings.TrimSpace(req.Version)
|
||||
req.ConnectorScope = strings.TrimSpace(req.ConnectorScope)
|
||||
req.PermissionScope = strings.TrimSpace(req.PermissionScope)
|
||||
req.ResourceBindings = strings.TrimSpace(req.ResourceBindings)
|
||||
req.InfoSources = strings.TrimSpace(req.InfoSources)
|
||||
req.BaseSkills = strings.TrimSpace(req.BaseSkills)
|
||||
req.AIAssistance = strings.TrimSpace(req.AIAssistance)
|
||||
req.GeneratedSkills = strings.TrimSpace(req.GeneratedSkills)
|
||||
req.InputsRecordsJSON = strings.TrimSpace(req.InputsRecordsJSON)
|
||||
req.LegacySourceRecordsJSON = strings.TrimSpace(req.LegacySourceRecordsJSON)
|
||||
if req.InputsRecordsJSON == "" {
|
||||
req.InputsRecordsJSON = req.LegacySourceRecordsJSON
|
||||
}
|
||||
req.PermissionRecordsJSON = strings.TrimSpace(req.PermissionRecordsJSON)
|
||||
req.ActionRecordsJSON = strings.TrimSpace(req.ActionRecordsJSON)
|
||||
req.ResultRecordsJSON = strings.TrimSpace(req.ResultRecordsJSON)
|
||||
req.State = strings.TrimSpace(req.State)
|
||||
}
|
||||
|
||||
func validateSpecialistReq(req *specialistReq) *web.AppError {
|
||||
normalizeSpecialistReq(req)
|
||||
if req.Key == "" || req.Label == "" || req.Tier == "" || req.Route == "" {
|
||||
return web.NewBadRequest("key、label、tier、route 为必填")
|
||||
}
|
||||
if req.Tier != "generic" && req.Tier != "industry" {
|
||||
return web.NewBadRequest("tier 只能是 generic 或 industry")
|
||||
}
|
||||
if req.WorkerType == "" {
|
||||
req.WorkerType = "dw"
|
||||
}
|
||||
if req.WorkerType != "dw" && req.WorkerType != "adw" {
|
||||
return web.NewBadRequest("worker_type 只能是 dw 或 adw")
|
||||
}
|
||||
if req.State == "" {
|
||||
req.State = "active"
|
||||
}
|
||||
if req.State != "active" && req.State != "inactive" {
|
||||
return web.NewBadRequest("state 只能是 active 或 inactive")
|
||||
}
|
||||
if req.MarketTag == "" {
|
||||
req.MarketTag = "已安装"
|
||||
}
|
||||
if req.Progress < 0 || req.Progress > 100 {
|
||||
return web.NewBadRequest("progress 必须在 0 到 100 之间")
|
||||
}
|
||||
if !store.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) {
|
||||
return web.NewBadRequest("inputs_records_json 必须是 JSON 数组")
|
||||
}
|
||||
if !store.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) {
|
||||
return web.NewBadRequest("permission_records_json 必须是 JSON 数组")
|
||||
}
|
||||
if !store.ValidateStructuredRecordsJSON(req.ActionRecordsJSON) {
|
||||
return web.NewBadRequest("action_records_json 必须是 JSON 数组")
|
||||
}
|
||||
if !store.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) {
|
||||
return web.NewBadRequest("result_records_json 必须是 JSON 数组")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSpecialist POST /api/specialists (admin)
|
||||
func CreateSpecialist(c *gin.Context) {
|
||||
var req specialistReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateSpecialistReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
store.DB.Model(&model.Specialist{}).Where("key = ?", req.Key).Count(&count)
|
||||
if count > 0 {
|
||||
web.Fail(c, web.NewConflictError("专员 key 已存在"))
|
||||
return
|
||||
}
|
||||
|
||||
item := model.Specialist{
|
||||
Key: req.Key,
|
||||
Label: req.Label,
|
||||
Tier: req.Tier,
|
||||
WorkerType: req.WorkerType,
|
||||
Route: req.Route,
|
||||
Summary: req.Summary,
|
||||
WorkStatus: req.WorkStatus,
|
||||
RiskLabel: req.RiskLabel,
|
||||
Color: req.Color,
|
||||
Stage: req.Stage,
|
||||
Progress: req.Progress,
|
||||
MarketTag: req.MarketTag,
|
||||
Version: req.Version,
|
||||
ConnectorScope: req.ConnectorScope,
|
||||
PermissionScope: req.PermissionScope,
|
||||
ResourceBindings: req.ResourceBindings,
|
||||
InfoSources: req.InfoSources,
|
||||
BaseSkills: req.BaseSkills,
|
||||
AIAssistance: req.AIAssistance,
|
||||
GeneratedSkills: req.GeneratedSkills,
|
||||
InputsRecordsJSON: req.InputsRecordsJSON,
|
||||
PermissionRecordsJSON: req.PermissionRecordsJSON,
|
||||
ActionRecordsJSON: req.ActionRecordsJSON,
|
||||
ResultRecordsJSON: req.ResultRecordsJSON,
|
||||
State: req.State,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
store.EnsureSpecialistStructuredRecords(&item)
|
||||
if err := store.DB.Create(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建专员失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
// UpdateSpecialist PUT /api/specialists/:id (admin)
|
||||
func UpdateSpecialist(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.Specialist
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("专员不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var req specialistReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateSpecialistReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
store.DB.Model(&model.Specialist{}).Where("key = ? AND id <> ?", req.Key, id).Count(&count)
|
||||
if count > 0 {
|
||||
web.Fail(c, web.NewConflictError("专员 key 已存在"))
|
||||
return
|
||||
}
|
||||
|
||||
item.Key = req.Key
|
||||
item.Label = req.Label
|
||||
item.Tier = req.Tier
|
||||
item.WorkerType = req.WorkerType
|
||||
item.Route = req.Route
|
||||
item.Summary = req.Summary
|
||||
item.WorkStatus = req.WorkStatus
|
||||
item.RiskLabel = req.RiskLabel
|
||||
item.Color = req.Color
|
||||
item.Stage = req.Stage
|
||||
item.Progress = req.Progress
|
||||
item.MarketTag = req.MarketTag
|
||||
item.Version = req.Version
|
||||
item.ConnectorScope = req.ConnectorScope
|
||||
item.PermissionScope = req.PermissionScope
|
||||
item.ResourceBindings = req.ResourceBindings
|
||||
item.InfoSources = req.InfoSources
|
||||
item.BaseSkills = req.BaseSkills
|
||||
item.AIAssistance = req.AIAssistance
|
||||
item.GeneratedSkills = req.GeneratedSkills
|
||||
item.InputsRecordsJSON = req.InputsRecordsJSON
|
||||
item.PermissionRecordsJSON = req.PermissionRecordsJSON
|
||||
item.ActionRecordsJSON = req.ActionRecordsJSON
|
||||
item.ResultRecordsJSON = req.ResultRecordsJSON
|
||||
item.State = req.State
|
||||
item.SortOrder = req.SortOrder
|
||||
store.EnsureSpecialistStructuredRecords(&item)
|
||||
|
||||
if err := store.DB.Save(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新专员失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
// DeleteSpecialist DELETE /api/specialists/:id (admin)
|
||||
func DeleteSpecialist(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.Specialist
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("专员不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除专员失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id, "deleted": true})
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// round1 保留 1 位小数
|
||||
func round1(f float64) float64 {
|
||||
return math.Round(f*10) / 10
|
||||
}
|
||||
|
||||
// examSummary 成绩汇总(传入某集合的 exam_record)
|
||||
func examSummary(recs []model.ExamRecord) gin.H {
|
||||
s := gin.H{"total": 0, "passed": 0, "failed": 0, "pass_rate": 0.0, "avg_score": 0.0, "max_score": 0, "min_score": 0}
|
||||
if len(recs) == 0 {
|
||||
return s
|
||||
}
|
||||
passed, sum, maxS, minS := 0, 0, -1, 1000000
|
||||
for _, r := range recs {
|
||||
if r.Passed {
|
||||
passed++
|
||||
}
|
||||
sum += r.Score
|
||||
if r.Score > maxS {
|
||||
maxS = r.Score
|
||||
}
|
||||
if r.Score < minS {
|
||||
minS = r.Score
|
||||
}
|
||||
}
|
||||
s["total"] = len(recs)
|
||||
s["passed"] = passed
|
||||
s["failed"] = len(recs) - passed
|
||||
s["pass_rate"] = round1(float64(passed) * 100 / float64(len(recs)))
|
||||
s["avg_score"] = round1(float64(sum) / float64(len(recs)))
|
||||
s["max_score"] = maxS
|
||||
s["min_score"] = minS
|
||||
return s
|
||||
}
|
||||
|
||||
// AdminDashboard GET /api/system/dashboard —— 管理员首页运营指标
|
||||
func AdminDashboard(c *gin.Context) {
|
||||
var employeeCount, adminCount, productCount, courseCount, questionCount, formalPaperCount int64
|
||||
var pendingMedia, pendingKnowledge, recentUsers int64
|
||||
|
||||
store.DB.Model(&model.User{}).Where("role = ?", "employee").Count(&employeeCount)
|
||||
store.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
|
||||
store.DB.Model(&model.Product{}).Where("status = ?", "active").Count(&productCount)
|
||||
store.DB.Model(&model.Course{}).Where("status = ?", "active").Count(&courseCount)
|
||||
store.DB.Model(&model.Question{}).Where("status = ?", "active").Count(&questionCount)
|
||||
store.DB.Model(&model.ExamPaper{}).Where("type = ? AND status = ?", "formal", "active").Count(&formalPaperCount)
|
||||
store.DB.Model(&model.MediaFile{}).Where("status = ?", "pending").Count(&pendingMedia)
|
||||
store.DB.Model(&model.KnowledgeSource{}).Where("audit_status = ?", "pending").Count(&pendingKnowledge)
|
||||
store.DB.Model(&model.User{}).Where("created_at >= ?", time.Now().AddDate(0, 0, -7)).Count(&recentUsers)
|
||||
|
||||
// 近 7 天每日新增(首页迷你柱状图数据源)
|
||||
now := time.Now()
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6)
|
||||
var newRows []struct {
|
||||
Date string `gorm:"column:d"`
|
||||
Count int64 `gorm:"column:c"`
|
||||
}
|
||||
store.DB.Raw(`SELECT strftime('%Y-%m-%d', created_at) AS d, COUNT(*) AS c
|
||||
FROM user WHERE created_at >= ? GROUP BY d`, dayStart).Scan(&newRows)
|
||||
newByDay := map[string]int64{}
|
||||
for _, r := range newRows {
|
||||
newByDay[r.Date] = r.Count
|
||||
}
|
||||
newUsers7d := make([]gin.H, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
d := dayStart.AddDate(0, 0, i).Format("2006-01-02")
|
||||
newUsers7d = append(newUsers7d, gin.H{"date": d, "count": newByDay[d]})
|
||||
}
|
||||
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Find(&recs)
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"employee_count": employeeCount,
|
||||
"admin_count": adminCount,
|
||||
"product_count": productCount,
|
||||
"course_count": courseCount,
|
||||
"question_count": questionCount,
|
||||
"formal_paper_count": formalPaperCount,
|
||||
"pending_material_count": pendingMedia + pendingKnowledge,
|
||||
"recent_new_users": recentUsers,
|
||||
"new_users_7d": newUsers7d,
|
||||
"exam": examSummary(recs),
|
||||
})
|
||||
}
|
||||
|
||||
// ExamStats GET /api/system/exam-stats —— 成绩统计(汇总 + 按考试 + 分数段)
|
||||
func ExamStats(c *gin.Context) {
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Order("submitted_at DESC").Find(&recs)
|
||||
|
||||
// 按 paper 聚合
|
||||
var papers []model.ExamPaper
|
||||
store.DB.Order("id ASC").Find(&papers)
|
||||
paperName := map[uint]string{}
|
||||
paperType := map[uint]string{}
|
||||
for _, p := range papers {
|
||||
paperName[p.ID] = p.Name
|
||||
paperType[p.ID] = p.Type
|
||||
}
|
||||
|
||||
type paperAgg struct {
|
||||
PaperID uint `json:"paper_id"`
|
||||
ExamName string `json:"exam_name"`
|
||||
Type string `json:"type"`
|
||||
TakenCount int `json:"taken_count"`
|
||||
Passed int `json:"passed_count"`
|
||||
PassRate float64 `json:"pass_rate"`
|
||||
AvgScore float64 `json:"avg_score"`
|
||||
}
|
||||
pmap := map[uint]*paperAgg{}
|
||||
for _, r := range recs {
|
||||
a := pmap[r.PaperID]
|
||||
if a == nil {
|
||||
a = &paperAgg{PaperID: r.PaperID, ExamName: paperName[r.PaperID], Type: paperType[r.PaperID]}
|
||||
if a.ExamName == "" {
|
||||
a.ExamName = r.ExamName
|
||||
}
|
||||
pmap[r.PaperID] = a
|
||||
}
|
||||
a.TakenCount++
|
||||
if r.Passed {
|
||||
a.Passed++
|
||||
}
|
||||
}
|
||||
papersOut := make([]paperAgg, 0, len(pmap))
|
||||
for _, a := range pmap {
|
||||
a.PassRate = round1(float64(a.Passed) * 100 / float64(a.TakenCount))
|
||||
a.AvgScore = round1(avgScoreOf(recs, a.PaperID))
|
||||
papersOut = append(papersOut, *a)
|
||||
}
|
||||
|
||||
// 分数段分布
|
||||
bands := []string{"0-59", "60-69", "70-79", "80-89", "90-100"}
|
||||
bandCount := map[string]int{}
|
||||
for _, b := range bands {
|
||||
bandCount[b] = 0
|
||||
}
|
||||
for _, r := range recs {
|
||||
bandCount[scoreBand(r.Score)]++
|
||||
}
|
||||
distribution := make([]gin.H, 0, len(bands))
|
||||
for _, b := range bands {
|
||||
distribution = append(distribution, gin.H{"band": b, "count": bandCount[b]})
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"summary": examSummary(recs),
|
||||
"papers": papersOut,
|
||||
"distribution": distribution,
|
||||
})
|
||||
}
|
||||
|
||||
func avgScoreOf(recs []model.ExamRecord, paperID uint) float64 {
|
||||
sum, n := 0, 0
|
||||
for _, r := range recs {
|
||||
if r.PaperID == paperID {
|
||||
sum += r.Score
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(sum) / float64(n)
|
||||
}
|
||||
|
||||
func scoreBand(score int) string {
|
||||
switch {
|
||||
case score < 60:
|
||||
return "0-59"
|
||||
case score < 70:
|
||||
return "60-69"
|
||||
case score < 80:
|
||||
return "70-79"
|
||||
case score < 90:
|
||||
return "80-89"
|
||||
default:
|
||||
return "90-100"
|
||||
}
|
||||
}
|
||||
|
||||
// ExamCoverage GET /api/system/exam-coverage —— 正式考试覆盖度(缺考名单)
|
||||
func ExamCoverage(c *gin.Context) {
|
||||
var papers []model.ExamPaper
|
||||
store.DB.Where("type = ? AND status = ?", "formal", "active").Order("id ASC").Find(&papers)
|
||||
|
||||
var employees []model.User
|
||||
store.DB.Where("role = ? AND status = ?", "employee", "active").Order("id ASC").Find(&employees)
|
||||
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Order("submitted_at DESC").Find(&recs)
|
||||
// 每人每场取最新一次(正式考单次,取首条即可)
|
||||
type key struct{ uid, pid uint }
|
||||
lmap := map[key]model.ExamRecord{}
|
||||
for _, r := range recs {
|
||||
k := key{r.UserID, r.PaperID}
|
||||
if _, ok := lmap[k]; !ok {
|
||||
lmap[k] = r
|
||||
}
|
||||
}
|
||||
|
||||
papersOut := make([]gin.H, 0, len(papers))
|
||||
for _, p := range papers {
|
||||
var taken []gin.H
|
||||
var pending []gin.H
|
||||
for _, e := range employees {
|
||||
r, ok := lmap[key{e.ID, p.ID}]
|
||||
if ok {
|
||||
taken = append(taken, gin.H{
|
||||
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
|
||||
"department": e.Department, "score": r.Score, "passed": r.Passed,
|
||||
"submitted_at": r.SubmittedAt,
|
||||
})
|
||||
} else {
|
||||
pending = append(pending, gin.H{
|
||||
"user_id": e.ID, "username": e.Username, "full_name": e.FullName,
|
||||
"department": e.Department,
|
||||
})
|
||||
}
|
||||
}
|
||||
papersOut = append(papersOut, gin.H{
|
||||
"paper_id": p.ID,
|
||||
"exam_name": p.Name,
|
||||
"employee_count": len(employees),
|
||||
"taken_count": len(taken),
|
||||
"pending_count": len(pending),
|
||||
"taken": taken,
|
||||
"pending": pending,
|
||||
})
|
||||
}
|
||||
web.OK(c, gin.H{"papers": papersOut})
|
||||
}
|
||||
|
||||
// PositionExamStats GET /api/system/exam-stats-by-position —— 成绩按岗位聚合
|
||||
func PositionExamStats(c *gin.Context) {
|
||||
var positions []model.Position
|
||||
store.DB.Where("status = ?", "active").Order("id ASC").Find(&positions)
|
||||
|
||||
var users []model.User
|
||||
store.DB.Find(&users)
|
||||
userPos := map[uint]uint{} // user_id -> position_id
|
||||
posEmployee := map[uint]int{} // position_id -> 员工数
|
||||
for _, u := range users {
|
||||
if u.PositionID == nil {
|
||||
continue
|
||||
}
|
||||
userPos[u.ID] = *u.PositionID
|
||||
posEmployee[*u.PositionID]++
|
||||
}
|
||||
|
||||
type posAgg struct {
|
||||
PositionID uint `json:"position_id"`
|
||||
PositionName string `json:"position_name"`
|
||||
EmployeeCount int `json:"employee_count"`
|
||||
TakenCount int `json:"taken_count"`
|
||||
PassedCount int `json:"passed_count"`
|
||||
PassRate float64 `json:"pass_rate"`
|
||||
AvgScore float64 `json:"avg_score"`
|
||||
}
|
||||
agg := map[uint]*posAgg{}
|
||||
for _, p := range positions {
|
||||
agg[p.ID] = &posAgg{PositionID: p.ID, PositionName: p.Name, EmployeeCount: posEmployee[p.ID]}
|
||||
}
|
||||
scoreSum := map[uint]int{}
|
||||
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Find(&recs)
|
||||
for _, r := range recs {
|
||||
pid, ok := userPos[r.UserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
a := agg[pid]
|
||||
if a == nil {
|
||||
a = &posAgg{PositionID: pid}
|
||||
agg[pid] = a
|
||||
}
|
||||
a.TakenCount++
|
||||
if r.Passed {
|
||||
a.PassedCount++
|
||||
}
|
||||
scoreSum[pid] += r.Score
|
||||
}
|
||||
|
||||
out := make([]posAgg, 0, len(agg))
|
||||
for _, p := range positions {
|
||||
a := agg[p.ID]
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
if a.TakenCount > 0 {
|
||||
a.PassRate = round1(float64(a.PassedCount) * 100 / float64(a.TakenCount))
|
||||
a.AvgScore = round1(float64(scoreSum[p.ID]) / float64(a.TakenCount))
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
web.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// UserOverview GET /api/system/users/{id}/overview —— 员工综合画像(考试 + AI 用量 + 学习进度)
|
||||
func UserOverview(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var u model.User
|
||||
if err := store.DB.First(&u, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("用户不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Where("user_id = ?", id).Order("submitted_at DESC").Find(&recs)
|
||||
|
||||
type aiAgg struct {
|
||||
Calls int64 `json:"calls"`
|
||||
Success int64 `json:"success"`
|
||||
Credits int64 `json:"credits"`
|
||||
}
|
||||
var ai aiAgg
|
||||
store.DB.Raw(`SELECT COUNT(*) AS calls,
|
||||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success,
|
||||
COALESCE(SUM(credits_charged), 0) AS credits
|
||||
FROM ai_call_log WHERE user_id = ?`, id).Scan(&ai)
|
||||
|
||||
var lps []model.LearningProgress
|
||||
store.DB.Where("user_id = ?", id).Order("updated_at DESC").Find(&lps)
|
||||
companyViewed := false
|
||||
productCount, courseCount := 0, 0
|
||||
var lastViewed *time.Time
|
||||
for _, lp := range lps {
|
||||
switch lp.ItemType {
|
||||
case "company":
|
||||
companyViewed = true
|
||||
case "product":
|
||||
productCount++
|
||||
case "course":
|
||||
courseCount++
|
||||
}
|
||||
if lastViewed == nil || lp.UpdatedAt.After(*lastViewed) {
|
||||
t := lp.UpdatedAt
|
||||
lastViewed = &t
|
||||
}
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"user": gin.H{
|
||||
"id": u.ID, "username": u.Username, "full_name": u.FullName,
|
||||
"role": u.Role, "status": u.Status, "ai_points": u.AiPoints,
|
||||
"department": u.Department, "position": u.Position, "hire_batch": u.HireBatch,
|
||||
"created_at": u.CreatedAt,
|
||||
},
|
||||
"exam": gin.H{
|
||||
"summary": examSummary(recs),
|
||||
"records": recs,
|
||||
},
|
||||
"ai_usage": gin.H{
|
||||
"total_calls": ai.Calls,
|
||||
"success_calls": ai.Success,
|
||||
"total_credits": ai.Credits,
|
||||
"ai_points_left": u.AiPoints,
|
||||
},
|
||||
"learning": gin.H{
|
||||
"company_viewed": companyViewed,
|
||||
"product_count": productCount,
|
||||
"course_count": courseCount,
|
||||
"total_items": len(lps),
|
||||
"last_viewed_at": lastViewed,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/auth"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// ============ 用户管理(管理员) ============
|
||||
|
||||
// ListUsers GET /api/system/users —— 含考试统计(考试次数/通过数/最近成绩)
|
||||
func ListUsers(c *gin.Context) {
|
||||
var users []model.User
|
||||
if err := store.DB.Order("id ASC").Find(&users).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询用户失败"))
|
||||
return
|
||||
}
|
||||
var recs []model.ExamRecord
|
||||
store.DB.Order("submitted_at DESC").Find(&recs)
|
||||
|
||||
type stat struct {
|
||||
ExamCount int
|
||||
PassedCount int
|
||||
LatestScore *int
|
||||
LatestPassed *bool
|
||||
LatestExamName string
|
||||
LatestSubmittedAt *time.Time
|
||||
}
|
||||
stats := map[uint]*stat{}
|
||||
for _, r := range recs {
|
||||
s := stats[r.UserID]
|
||||
if s == nil {
|
||||
s = &stat{}
|
||||
stats[r.UserID] = s
|
||||
}
|
||||
s.ExamCount++
|
||||
if r.Passed {
|
||||
s.PassedCount++
|
||||
}
|
||||
if s.LatestSubmittedAt == nil {
|
||||
sc, ps := r.Score, r.Passed
|
||||
t := r.SubmittedAt
|
||||
s.LatestScore, s.LatestPassed = &sc, &ps
|
||||
s.LatestExamName = r.ExamName
|
||||
s.LatestSubmittedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(users))
|
||||
for _, u := range users {
|
||||
h := gin.H{
|
||||
"id": u.ID, "username": u.Username, "full_name": u.FullName,
|
||||
"role": u.Role, "status": u.Status, "ai_points": u.AiPoints,
|
||||
"department": u.Department, "position": u.Position, "hire_batch": u.HireBatch,
|
||||
"position_id": u.PositionID,
|
||||
"created_at": u.CreatedAt,
|
||||
"exam_count": 0, "passed_count": 0,
|
||||
"latest_score": nil, "latest_passed": nil, "latest_exam_name": "", "latest_submitted_at": nil,
|
||||
}
|
||||
if s := stats[u.ID]; s != nil {
|
||||
h["exam_count"] = s.ExamCount
|
||||
h["passed_count"] = s.PassedCount
|
||||
h["latest_score"] = s.LatestScore
|
||||
h["latest_passed"] = s.LatestPassed
|
||||
h["latest_exam_name"] = s.LatestExamName
|
||||
h["latest_submitted_at"] = s.LatestSubmittedAt
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
web.OK(c, out)
|
||||
}
|
||||
|
||||
// CreateUser POST /api/system/users —— {username,password,full_name,role,department,position,hire_batch}
|
||||
func CreateUser(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
HireBatch string `json:"hire_batch"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" || req.FullName == "" {
|
||||
web.Fail(c, web.NewBadRequest("username/password/full_name 必填"))
|
||||
return
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "employee" {
|
||||
req.Role = "employee"
|
||||
}
|
||||
var count int64
|
||||
store.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
|
||||
if count > 0 {
|
||||
web.Fail(c, web.NewConflictError("用户名已存在"))
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("密码加密失败"))
|
||||
return
|
||||
}
|
||||
u := model.User{
|
||||
Username: req.Username, PasswordHash: hash, FullName: req.FullName,
|
||||
Role: req.Role, Status: "active", AiPoints: defaultAiPoints(),
|
||||
Department: req.Department, Position: req.Position, HireBatch: req.HireBatch,
|
||||
}
|
||||
if req.Role == "admin" {
|
||||
u.AiPoints = 999999
|
||||
}
|
||||
if err := store.DB.Create(&u).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建用户失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, u)
|
||||
}
|
||||
|
||||
// UpdateUser PUT /api/system/users/{id} —— 编辑(可选改密/禁用/改角色)
|
||||
func UpdateUser(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var u model.User
|
||||
if err := store.DB.First(&u, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("用户不存在"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Password string `json:"password"`
|
||||
AiPoints *int `json:"ai_points"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
HireBatch string `json:"hire_batch"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.FullName != "" {
|
||||
u.FullName = req.FullName
|
||||
}
|
||||
if req.Role == "admin" || req.Role == "employee" {
|
||||
u.Role = req.Role
|
||||
}
|
||||
if req.Status == "active" || req.Status == "disabled" {
|
||||
u.Status = req.Status
|
||||
}
|
||||
if req.Department != "" {
|
||||
u.Department = req.Department
|
||||
}
|
||||
if req.Position != "" {
|
||||
u.Position = req.Position
|
||||
}
|
||||
if req.HireBatch != "" {
|
||||
u.HireBatch = req.HireBatch
|
||||
}
|
||||
if req.Password != "" {
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("密码加密失败"))
|
||||
return
|
||||
}
|
||||
u.PasswordHash = hash
|
||||
}
|
||||
if req.AiPoints != nil {
|
||||
if *req.AiPoints < 0 {
|
||||
web.Fail(c, web.NewBadRequest("ai_points 不能为负"))
|
||||
return
|
||||
}
|
||||
u.AiPoints = *req.AiPoints
|
||||
}
|
||||
if err := store.DB.Save(&u).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新用户失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, u)
|
||||
}
|
||||
|
||||
// defaultAiPoints 读取新用户默认 AI 算力点(system_config.ai_points_default,缺省 100)
|
||||
func defaultAiPoints() int {
|
||||
var sc model.SystemConfig
|
||||
if err := store.DB.Where("config_key = ?", "ai_points_default").First(&sc).Error; err == nil {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(sc.ConfigValue)); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
// ============ 成绩管理(管理员) ============
|
||||
|
||||
// ListExamRecords GET /api/system/exam-records?user_id=&paper_id=
|
||||
func ListExamRecords(c *gin.Context) {
|
||||
q := store.DB.Model(&model.ExamRecord{})
|
||||
if uid := c.Query("user_id"); uid != "" {
|
||||
q = q.Where("user_id = ?", uid)
|
||||
}
|
||||
if pid := c.Query("paper_id"); pid != "" {
|
||||
q = q.Where("paper_id = ?", pid)
|
||||
}
|
||||
var items []model.ExamRecord
|
||||
if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询成绩失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// GetExamRecord GET /api/system/exam-records/{id} —— 详情
|
||||
func GetExamRecord(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rec model.ExamRecord
|
||||
if err := store.DB.First(&rec, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("考试记录不存在"))
|
||||
return
|
||||
}
|
||||
var detail any
|
||||
_ = json.Unmarshal([]byte(rec.DetailJSON), &detail)
|
||||
web.OK(c, gin.H{
|
||||
"id": rec.ID, "user_id": rec.UserID, "paper_id": rec.PaperID, "exam_name": rec.ExamName,
|
||||
"score": rec.Score, "total_score": rec.TotalScore, "pass_score": rec.PassScore, "passed": rec.Passed,
|
||||
"correct_count": rec.CorrectCount, "wrong_count": rec.WrongCount,
|
||||
"detail": detail, "submitted_at": rec.SubmittedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExamRecord DELETE /api/system/exam-records/{id} —— 删除成绩记录(用于重置正式考重考资格)
|
||||
func DeleteExamRecord(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rec model.ExamRecord
|
||||
if err := store.DB.First(&rec, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("考试记录不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&rec).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除考试记录失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id, "deleted": true})
|
||||
}
|
||||
|
||||
// ExportExamRecords GET /api/system/exam-records/export —— 导出 CSV(支持 user_id/paper_id 过滤)
|
||||
func ExportExamRecords(c *gin.Context) {
|
||||
q := store.DB.Model(&model.ExamRecord{})
|
||||
if uid := c.Query("user_id"); uid != "" {
|
||||
q = q.Where("user_id = ?", uid)
|
||||
}
|
||||
if pid := c.Query("paper_id"); pid != "" {
|
||||
q = q.Where("paper_id = ?", pid)
|
||||
}
|
||||
var items []model.ExamRecord
|
||||
if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询成绩失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 用户名映射
|
||||
var users []model.User
|
||||
store.DB.Find(&users)
|
||||
nameMap := map[uint]model.User{}
|
||||
for _, u := range users {
|
||||
nameMap[u.ID] = u
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", `attachment; filename="exam_records.csv"`)
|
||||
c.Writer.WriteString("\xEF\xBB\xBF") // UTF-8 BOM,兼容 Excel
|
||||
w := csv.NewWriter(c.Writer)
|
||||
_ = w.Write([]string{"编号", "用户名", "姓名", "部门", "考试名称", "得分", "总分", "结果", "答对", "答错", "提交时间"})
|
||||
for _, r := range items {
|
||||
u := nameMap[r.UserID]
|
||||
passed := "未通过"
|
||||
if r.Passed {
|
||||
passed = "通过"
|
||||
}
|
||||
_ = w.Write([]string{
|
||||
strconv.FormatUint(uint64(r.ID), 10),
|
||||
u.Username,
|
||||
u.FullName,
|
||||
u.Department,
|
||||
r.ExamName,
|
||||
strconv.Itoa(r.Score),
|
||||
strconv.Itoa(r.TotalScore),
|
||||
passed,
|
||||
strconv.Itoa(r.CorrectCount),
|
||||
strconv.Itoa(r.WrongCount),
|
||||
r.SubmittedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// ============ 系统参数配置(管理员) ============
|
||||
|
||||
// GetConfig GET /api/system/config —— 所有系统参数
|
||||
func GetConfig(c *gin.Context) {
|
||||
var items []model.SystemConfig
|
||||
store.DB.Order("id ASC").Find(&items)
|
||||
type cfgItem struct {
|
||||
Key string `json:"config_key"`
|
||||
Value string `json:"config_value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
out := make([]cfgItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, cfgItem{Key: it.ConfigKey, Value: it.ConfigValue, Description: it.Description})
|
||||
}
|
||||
web.OK(c, gin.H{"configs": out})
|
||||
}
|
||||
|
||||
// UpdateConfig PUT /api/system/config —— {configs: {key: value}}
|
||||
func UpdateConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
Configs map[string]string `json:"configs"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Configs) == 0 {
|
||||
web.Fail(c, web.NewBadRequest("configs 必填"))
|
||||
return
|
||||
}
|
||||
for k, v := range req.Configs {
|
||||
var sc model.SystemConfig
|
||||
if err := store.DB.Where("config_key = ?", k).First(&sc).Error; err == nil {
|
||||
sc.ConfigValue = v
|
||||
store.DB.Save(&sc)
|
||||
} else {
|
||||
store.DB.Create(&model.SystemConfig{ConfigKey: k, ConfigValue: v})
|
||||
}
|
||||
}
|
||||
web.OK(c, gin.H{"updated": len(req.Configs)})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user