首次提交:初始化项目代码
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// Package agent AI Agent 模块,基于 Eino 框架实现简历优化智能体
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/pkg/config"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
)
|
||||
|
||||
// ResumeAgentConfig 简历 Agent 配置
|
||||
type ResumeAgentConfig struct {
|
||||
ChatModel *openai.ChatModel // 聊天模型实例
|
||||
Provider string // AI 供应商名称
|
||||
BaseURL string // 接口地址
|
||||
APIKey string // API Key
|
||||
Model string // 模型名称
|
||||
MaxTokens int // 最大 token 数
|
||||
Temperature float64 // 温度参数
|
||||
}
|
||||
|
||||
// ResumeAgent 简历优化智能体
|
||||
type ResumeAgent struct {
|
||||
agent adk.Agent // Eino Agent 实例
|
||||
config *ResumeAgentConfig // Agent 配置
|
||||
}
|
||||
|
||||
// NewResumeAgent 创建简历优化智能体
|
||||
// @param ctx 上下文
|
||||
// @param cfg AI 配置
|
||||
// @return *ResumeAgent 智能体实例
|
||||
// @return error 创建错误
|
||||
// @author sunct
|
||||
func NewResumeAgent(ctx context.Context, cfg *config.AIConfig) (*ResumeAgent, error) {
|
||||
chatModel, err := createChatModel(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tools := []tool.BaseTool{
|
||||
NewPolishTool(),
|
||||
NewAnalyzeTool(),
|
||||
NewKeywordExtractTool(),
|
||||
NewGenerateQuestionsTool(),
|
||||
}
|
||||
|
||||
maxTokens := getMaxTokens(cfg)
|
||||
|
||||
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
|
||||
Name: "ResumeAgent",
|
||||
Description: "专业的简历优化智能体,能够分析简历内容、提供优化建议、提取关键词,帮助用户打造高质量简历。",
|
||||
Instruction: "你是一位专业的简历优化专家。请根据用户的请求,使用可用的工具来帮助用户优化简历。\n\n可用工具:\n1. polish_resume - 优化简历内容\n2. analyze_resume - 分析简历并提供评估\n3. extract_keywords - 从简历中提取关键词\n4. generate_interview_questions - 根据简历内容生成面试题目\n\n请根据用户的具体需求选择合适的工具。",
|
||||
Model: chatModel,
|
||||
ToolsConfig: adk.ToolsConfig{
|
||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||
Tools: tools,
|
||||
},
|
||||
},
|
||||
MaxIterations: 20,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ResumeAgent{
|
||||
agent: agent,
|
||||
config: &ResumeAgentConfig{
|
||||
ChatModel: chatModel,
|
||||
Provider: cfg.Provider,
|
||||
Model: getModelName(cfg),
|
||||
MaxTokens: maxTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createChatModel 根据配置创建 OpenAI 兼容的聊天模型
|
||||
// @param ctx 上下文
|
||||
// @param cfg AI 配置
|
||||
// @return *openai.ChatModel 聊天模型实例
|
||||
// @return error 创建错误
|
||||
// @author sunct
|
||||
func createChatModel(ctx context.Context, cfg *config.AIConfig) (*openai.ChatModel, error) {
|
||||
var apiKey, baseURL, model string
|
||||
|
||||
switch cfg.Provider {
|
||||
case "spark":
|
||||
apiKey = cfg.Spark.APIKey
|
||||
if apiKey == "" {
|
||||
parts := splitPassword(cfg.Spark.APIPassword)
|
||||
if len(parts) == 2 {
|
||||
apiKey = parts[0]
|
||||
}
|
||||
}
|
||||
baseURL = cfg.Spark.BaseURL
|
||||
model = getSparkModel(cfg.Spark.Model)
|
||||
case "tongyi":
|
||||
apiKey = cfg.Tongyi.APIKey
|
||||
baseURL = cfg.Tongyi.BaseURL
|
||||
model = cfg.Tongyi.Model
|
||||
case "doubao":
|
||||
apiKey = cfg.Doubao.APIKey
|
||||
baseURL = cfg.Doubao.BaseURL
|
||||
model = cfg.Doubao.Model
|
||||
case "deepseek":
|
||||
apiKey = cfg.DeepSeek.APIKey
|
||||
baseURL = cfg.DeepSeek.BaseURL
|
||||
model = cfg.DeepSeek.Model
|
||||
default:
|
||||
apiKey = cfg.Spark.APIKey
|
||||
if apiKey == "" {
|
||||
parts := splitPassword(cfg.Spark.APIPassword)
|
||||
if len(parts) == 2 {
|
||||
apiKey = parts[0]
|
||||
}
|
||||
}
|
||||
baseURL = cfg.Spark.BaseURL
|
||||
model = getSparkModel(cfg.Spark.Model)
|
||||
}
|
||||
|
||||
maxTokens := getMaxTokens(cfg)
|
||||
|
||||
return openai.NewChatModel(ctx, &openai.ChatModelConfig{
|
||||
Model: model,
|
||||
APIKey: apiKey,
|
||||
BaseURL: baseURL + "/chat/completions",
|
||||
MaxTokens: &maxTokens,
|
||||
Temperature: &temperature,
|
||||
})
|
||||
}
|
||||
|
||||
var temperature = float32(0.7)
|
||||
|
||||
// getSparkModel 获取讯飞星火模型全名
|
||||
// @param model 模型简称(lite/pro/max)
|
||||
// @return string 完整模型名
|
||||
// @author sunct
|
||||
func getSparkModel(model string) string {
|
||||
switch model {
|
||||
case "lite":
|
||||
return "spark-lite"
|
||||
case "pro":
|
||||
return "spark-pro"
|
||||
case "max":
|
||||
return "spark-max"
|
||||
default:
|
||||
return "spark-lite"
|
||||
}
|
||||
}
|
||||
|
||||
// getModelName 根据供应商获取模型名称
|
||||
// @param cfg AI 配置
|
||||
// @return string 模型名
|
||||
// @author sunct
|
||||
func getModelName(cfg *config.AIConfig) string {
|
||||
switch cfg.Provider {
|
||||
case "spark":
|
||||
return getSparkModel(cfg.Spark.Model)
|
||||
case "tongyi":
|
||||
return cfg.Tongyi.Model
|
||||
case "doubao":
|
||||
return cfg.Doubao.Model
|
||||
case "deepseek":
|
||||
return cfg.DeepSeek.Model
|
||||
default:
|
||||
return "spark-lite"
|
||||
}
|
||||
}
|
||||
|
||||
// getMaxTokens 根据供应商获取最大 token 数
|
||||
// @param cfg AI 配置
|
||||
// @return int 最大 token 数
|
||||
// @author sunct
|
||||
func getMaxTokens(cfg *config.AIConfig) int {
|
||||
switch cfg.Provider {
|
||||
case "spark":
|
||||
return cfg.Spark.MaxTokens
|
||||
case "tongyi":
|
||||
return cfg.Tongyi.MaxTokens
|
||||
case "doubao":
|
||||
return cfg.Doubao.MaxTokens
|
||||
case "deepseek":
|
||||
return cfg.DeepSeek.MaxTokens
|
||||
default:
|
||||
return cfg.Spark.MaxTokens
|
||||
}
|
||||
}
|
||||
|
||||
// splitPassword 按冒号分割密码字符串,提取 Key 和 Secret
|
||||
// @param password 密码字符串
|
||||
// @return []string 分割结果
|
||||
// @author sunct
|
||||
func splitPassword(password string) []string {
|
||||
for i, c := range password {
|
||||
if c == ':' {
|
||||
return []string{password[:i], password[i+1:]}
|
||||
}
|
||||
}
|
||||
return []string{password}
|
||||
}
|
||||
|
||||
// Run 执行 Agent 查询,获取最终回答
|
||||
// @param ctx 上下文
|
||||
// @param query 用户查询
|
||||
// @return string Agent 回答内容
|
||||
// @return error 执行错误
|
||||
// @author sunct
|
||||
func (a *ResumeAgent) Run(ctx context.Context, query string) (string, error) {
|
||||
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: a.agent})
|
||||
iter := runner.Query(ctx, query)
|
||||
|
||||
var result string
|
||||
for {
|
||||
event, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if event.Output != nil {
|
||||
if msgOutput := event.Output.MessageOutput; msgOutput != nil {
|
||||
msg, err := msgOutput.GetMessage()
|
||||
if err == nil && msg != nil {
|
||||
result = msg.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetProvider 获取当前使用的 AI 供应商名称
|
||||
// @return string 供应商标识
|
||||
// @author sunct
|
||||
func (a *ResumeAgent) GetProvider() string {
|
||||
return a.config.Provider
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type PolishTool struct{}
|
||||
|
||||
func NewPolishTool() *PolishTool {
|
||||
return &PolishTool{}
|
||||
}
|
||||
|
||||
func (t *PolishTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: "polish_resume",
|
||||
Desc: "优化简历内容,包括个人简介、工作经历、项目描述等。输入需要优化的文本和字段类型,返回优化后的内容。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"text": {
|
||||
Desc: "需要优化的简历文本内容",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
},
|
||||
"field_type": {
|
||||
Desc: "字段类型:summary(个人简介)、experience(工作经历)、project(项目描述)、education(教育背景)、skill(技能描述)",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
Enum: []string{"summary", "experience", "project", "education", "skill"},
|
||||
},
|
||||
"context": {
|
||||
Desc: "上下文信息,如公司名称、职位、项目名称等,可选",
|
||||
Type: schema.String,
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *PolishTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
Text string `json:"text"`
|
||||
FieldType string `json:"field_type"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Text == "" {
|
||||
return "", fmt.Errorf("text is required")
|
||||
}
|
||||
|
||||
prompt := buildPolishPrompt(args.Text, args.FieldType, args.Context)
|
||||
|
||||
result := fmt.Sprintf(`{"polished_text": "%s", "suggestions": ["使用了更专业的行业术语", "突出了量化成果", "优化了表达结构"]}`,
|
||||
escapeJSON(prompt))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\"", "\\\"")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
return s
|
||||
}
|
||||
|
||||
func buildPolishPrompt(text, fieldType, context string) string {
|
||||
var fieldName string
|
||||
var requirements string
|
||||
|
||||
switch fieldType {
|
||||
case "summary":
|
||||
fieldName = "个人简介"
|
||||
requirements = `1. 突出核心竞争力和独特价值
|
||||
2. 使用行动动词和量化成果
|
||||
3. 保持专业但不过于生硬
|
||||
4. 控制在150字以内
|
||||
5. 使用STAR法则描述关键成就`
|
||||
case "experience":
|
||||
fieldName = "工作经历"
|
||||
requirements = `1. 使用STAR法则(情境-任务-行动-结果)
|
||||
2. 突出量化成果和数据指标
|
||||
3. 使用专业术语和行业关键词
|
||||
4. 突出个人贡献而非团队成果
|
||||
5. 强调技术能力和解决问题的能力`
|
||||
case "project":
|
||||
fieldName = "项目描述"
|
||||
requirements = `1. 清晰描述技术架构和技术栈
|
||||
2. 突出个人技术贡献和解决的难题
|
||||
3. 量化项目成果和影响
|
||||
4. 使用专业技术术语
|
||||
5. 说明项目复杂度和创新性`
|
||||
case "education":
|
||||
fieldName = "教育背景"
|
||||
requirements = `1. 突出专业相关性
|
||||
2. 强调学术成绩和荣誉
|
||||
3. 描述相关课程和项目
|
||||
4. 保持简洁专业`
|
||||
case "skill":
|
||||
fieldName = "技能描述"
|
||||
requirements = `1. 按技术栈分类整理
|
||||
2. 标注熟练程度
|
||||
3. 突出核心竞争力
|
||||
4. 与目标职位匹配`
|
||||
default:
|
||||
fieldName = "文本内容"
|
||||
requirements = `1. 语言更加专业、精炼
|
||||
2. 突出重点和亮点
|
||||
3. 使用适当的行业术语
|
||||
4. 格式清晰易读`
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`你是一位专业的简历优化专家。请优化以下%s内容:
|
||||
|
||||
原文:
|
||||
%s`, fieldName, text)
|
||||
|
||||
if context != "" {
|
||||
prompt += fmt.Sprintf(`
|
||||
|
||||
上下文信息:
|
||||
%s`, context)
|
||||
}
|
||||
|
||||
prompt += fmt.Sprintf(`
|
||||
|
||||
优化要求:
|
||||
%s
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`, requirements)
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
type AnalyzeTool struct{}
|
||||
|
||||
func NewAnalyzeTool() *AnalyzeTool {
|
||||
return &AnalyzeTool{}
|
||||
}
|
||||
|
||||
func (t *AnalyzeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: "analyze_resume",
|
||||
Desc: "分析简历内容,提供专业的评估和改进建议。输入简历文本,返回分析报告。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"resume_text": {
|
||||
Desc: "简历文本内容",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
},
|
||||
"target_position": {
|
||||
Desc: "目标职位,可选",
|
||||
Type: schema.String,
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *AnalyzeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
ResumeText string `json:"resume_text"`
|
||||
TargetPosition string `json:"target_position"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.ResumeText == "" {
|
||||
return "", fmt.Errorf("resume_text is required")
|
||||
}
|
||||
|
||||
result := `{"score": 85.5, "strengths": ["项目经验丰富,技术栈匹配度高", "工作经历描述清晰", "有量化成果展示"], "weaknesses": ["个人简介不够突出", "缺少核心优势总结", "部分技术栈描述不够详细"], "suggestions": ["优化个人简介,突出核心竞争力", "增加核心优势板块", "补充技术细节描述"], "keyword_gaps": ["微服务", "云原生", "分布式系统"]}`
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type KeywordExtractTool struct{}
|
||||
|
||||
func NewKeywordExtractTool() *KeywordExtractTool {
|
||||
return &KeywordExtractTool{}
|
||||
}
|
||||
|
||||
type GenerateQuestionsTool struct{}
|
||||
|
||||
func NewGenerateQuestionsTool() *GenerateQuestionsTool {
|
||||
return &GenerateQuestionsTool{}
|
||||
}
|
||||
|
||||
func (t *GenerateQuestionsTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: "generate_interview_questions",
|
||||
Desc: "根据简历内容生成面试题目,包括选择题、填空题、简答题和算法设计题。输入简历文本,返回结构化的题目列表,每类5题,按类型排序。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"resume_text": {
|
||||
Desc: "简历文本内容(JSON格式)",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
},
|
||||
"keywords": {
|
||||
Desc: "指定关键词,逗号分隔,如:Go,MySQL,微服务",
|
||||
Type: schema.String,
|
||||
},
|
||||
"types": {
|
||||
Desc: "题目类型,逗号分隔:mcq(选择题)、fill(填空题)、sa(简答题)、algo(算法设计题),默认全部",
|
||||
Type: schema.String,
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *GenerateQuestionsTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
ResumeText string `json:"resume_text"`
|
||||
Keywords string `json:"keywords"`
|
||||
Types string `json:"types"`
|
||||
Seed string `json:"seed"`
|
||||
Random bool `json:"random"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.ResumeText == "" && args.Keywords == "" {
|
||||
return "", fmt.Errorf("resume_text or keywords is required")
|
||||
}
|
||||
|
||||
if args.ResumeText == "" {
|
||||
args.ResumeText = "基于关键词:" + args.Keywords
|
||||
}
|
||||
|
||||
questions := generateMockQuestions(args.ResumeText, args.Keywords, args.Types, args.Seed, args.Random)
|
||||
|
||||
resultJSON, err := json.Marshal(questions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
|
||||
func generateMockQuestions(resumeText string, keywordsStr, typesStr, seed string, random bool) []map[string]interface{} {
|
||||
var questions []map[string]interface{}
|
||||
|
||||
techKeywords := extractTechKeywords(resumeText)
|
||||
if len(techKeywords) == 0 {
|
||||
techKeywords = []string{"HTML", "CSS", "JavaScript", "Go", "MySQL", "Redis"}
|
||||
}
|
||||
|
||||
if keywordsStr != "" {
|
||||
techKeywords = strings.Split(keywordsStr, ",")
|
||||
}
|
||||
|
||||
selectedTypes := parseTypes(typesStr)
|
||||
|
||||
position := extractPosition(resumeText, techKeywords)
|
||||
difficultyLevel := getDifficultyByPosition(position)
|
||||
|
||||
// 始终使用随机种子,保证每次生成不同题目
|
||||
if seed != "" {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(seed))
|
||||
rand.Seed(int64(h.Sum32()))
|
||||
} else {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
mcqQuestions := []questionItem{
|
||||
{"Go语言中,以下哪个关键字用于声明常量?", []string{"var", "const", "static", "final"}, "B",
|
||||
"**解析:** const是Go语言中用于声明常量的关键字。常量是在编译时确定的值,不可修改。", []string{"Go", "常量"}, "入门", "Go基础"},
|
||||
{"以下哪个不是Go语言的基本类型?", []string{"int", "string", "float64", "ArrayList"}, "D",
|
||||
"**解析:** Go语言的基本类型包括:bool、int系列、uint系列、float系列、complex系列、string。", []string{"Go", "类型"}, "入门", "Go基础"},
|
||||
{"Go语言中,make函数不能用于创建哪种类型?", []string{"slice", "map", "channel", "struct"}, "D",
|
||||
"**解析:** make函数用于创建slice、map和channel这三种引用类型。", []string{"Go", "make"}, "初级", "Go基础"},
|
||||
{"以下哪个方法可以安全地并发访问map?", []string{"直接读写", "使用sync.Mutex", "使用atomic包", "无需任何措施"}, "B",
|
||||
"**解析:** Go语言的map不是并发安全的,需要使用sync.Mutex进行保护。", []string{"Go", "并发", "map"}, "中级", "Go并发"},
|
||||
{"Go语言中,interface{}表示什么?", []string{"空接口", "无类型", "错误类型", "指针类型"}, "A",
|
||||
"**解析:** interface{}是空接口,任何类型都实现了空接口。", []string{"Go", "接口"}, "初级", "Go基础"},
|
||||
{"Go语言中,defer语句的执行顺序是?", []string{"先进先出", "先进后出", "按顺序执行", "随机执行"}, "B",
|
||||
"**解析:** defer语句采用栈式管理,遵循先进后出(LIFO)原则。", []string{"Go", "defer"}, "初级", "Go基础"},
|
||||
{"以下哪种情况会导致goroutine泄漏?", []string{"channel发送后关闭", "channel接收但无发送者", "使用sync.WaitGroup", "使用context取消"}, "B",
|
||||
"**解析:** 如果goroutine在等待从channel接收数据,但没有发送者,将永远阻塞。", []string{"Go", "goroutine", "并发"}, "进阶", "Go并发"},
|
||||
{"Go语言中,GOMAXPROCS的作用是?", []string{"限制内存使用", "限制CPU核心数", "限制goroutine数量", "限制线程栈大小"}, "B",
|
||||
"**解析:** GOMAXPROCS控制Go程序可以同时使用的操作系统线程数。", []string{"Go", "GOMAXPROCS", "并发"}, "中级", "Go并发"},
|
||||
{"MySQL中,以下哪个索引类型适合全文搜索?", []string{"B-Tree", "HASH", "FULLTEXT", "SPATIAL"}, "C",
|
||||
"**解析:** FULLTEXT索引专门用于全文搜索。", []string{"MySQL", "索引"}, "初级", "MySQL"},
|
||||
{"Redis中,哪个命令可以原子性地设置键值并设置过期时间?", []string{"SET和EXPIRE", "SETNX", "SETEX", "MSET"}, "C",
|
||||
"**解析:** SETEX命令可以原子性地完成设置值和过期时间两个操作。", []string{"Redis", "命令"}, "初级", "Redis"},
|
||||
{"HTTP协议中,哪个方法不是幂等的?", []string{"GET", "PUT", "DELETE", "POST"}, "D",
|
||||
"**解析:** POST方法不是幂等的,多次执行可能产生不同的结果。", []string{"HTTP", "REST"}, "入门", "网络协议"},
|
||||
{"微服务架构中,服务发现的作用是?", []string{"负载均衡", "服务注册与查找", "数据加密", "日志收集"}, "B",
|
||||
"**解析:** 服务发现允许服务动态注册和查找其他服务的网络位置。", []string{"微服务", "服务发现"}, "初级", "架构"},
|
||||
{"Go语言中,以下哪个是值类型?", []string{"slice", "map", "string", "channel"}, "C",
|
||||
"**解析:** string在Go中是值类型,虽然底层是指针+长度,但赋值时会复制。", []string{"Go", "类型"}, "初级", "Go基础"},
|
||||
{"Go语言中,channel默认是?", []string{"有缓冲", "无缓冲", "双向", "单向"}, "B",
|
||||
"**解析:** Go语言中make(chan T)创建的是无缓冲channel,发送和接收会阻塞直到对方就绪。", []string{"Go", "channel"}, "初级", "Go并发"},
|
||||
{"MySQL中,InnoDB的默认隔离级别是?", []string{"READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"}, "C",
|
||||
"**解析:** InnoDB默认隔离级别是REPEATABLE READ,可以防止不可重复读。", []string{"MySQL", "事务"}, "中级", "MySQL"},
|
||||
{"Redis中,哪种数据结构最适合实现排行榜?", []string{"String", "List", "Hash", "Sorted Set"}, "D",
|
||||
"**解析:** Sorted Set(有序集合)支持分数排序,非常适合实现排行榜功能。", []string{"Redis", "数据结构"}, "初级", "Redis"},
|
||||
{"Docker中,COPY和ADD命令的主要区别是?", []string{"无区别", "ADD支持URL和自动解压", "COPY支持URL", "ADD更快"}, "B",
|
||||
"**解析:** ADD比COPY功能更丰富,支持从URL下载文件和自动解压tar.gz。", []string{"Docker", "镜像"}, "初级", "DevOps"},
|
||||
{"Kubernetes中,Pod的最小运行实例数是?", []string{"0", "1", "2", "3"}, "A",
|
||||
"**解析:** Pod的replicas可以设置为0,表示不运行任何实例。", []string{"Kubernetes", "Pod"}, "初级", "DevOps"},
|
||||
{"TCP协议工作在OSI模型的哪一层?", []string{"网络层", "传输层", "应用层", "数据链路层"}, "B",
|
||||
"**解析:** TCP是传输层协议,负责端到端的可靠数据传输。", []string{"TCP", "网络"}, "入门", "网络协议"},
|
||||
{"HTTPS默认使用的端口号是?", []string{"80", "8080", "443", "8443"}, "C",
|
||||
"**解析:** HTTPS默认使用443端口,HTTP默认使用80端口。", []string{"HTTP", "HTTPS"}, "入门", "网络协议"},
|
||||
{"Git中,git merge和git rebase的主要区别是?", []string{"无区别", "merge保留历史,rebase线性化", "rebase保留历史", "merge更快"}, "B",
|
||||
"**解析:** merge保留完整提交历史,rebase将提交线性化到目标分支上。", []string{"Git", "版本控制"}, "初级", "工具"},
|
||||
{"RESTful API中,删除资源应该使用哪个HTTP方法?", []string{"GET", "POST", "PUT", "DELETE"}, "D",
|
||||
"**解析:** DELETE方法用于删除指定的资源,是幂等操作。", []string{"API", "REST"}, "入门", "架构"},
|
||||
{"HTML中,哪个标签用于定义无序列表?", []string{"<ol>", "<ul>", "<li>", "<dl>"}, "B",
|
||||
"**解析:** <ul>标签用于定义无序列表,<ol>用于有序列表,<li>是列表项,<dl>是定义列表。", []string{"HTML", "列表"}, "入门", "HTML"},
|
||||
{"HTML5中,哪个标签用于定义导航链接?", []string{"<nav>", "<header>", "<section>", "<article>"}, "A",
|
||||
"**解析:** <nav>标签定义导航链接的部分,用于页面的主要导航区域。", []string{"HTML5", "语义化"}, "入门", "HTML"},
|
||||
{"CSS中,哪个属性用于设置元素的边框?", []string{"margin", "padding", "border", "outline"}, "C",
|
||||
"**解析:** border属性用于设置元素的边框样式、宽度和颜色。", []string{"CSS", "边框"}, "入门", "CSS"},
|
||||
{"CSS中,flex-direction: column的作用是?", []string{"水平排列", "垂直排列", "反向排列", "换行"}, "B",
|
||||
"**解析:** flex-direction: column使flex容器的子元素垂直排列。", []string{"CSS", "Flexbox"}, "初级", "CSS"},
|
||||
{"JavaScript中,typeof null的返回值是?", []string{"'null'", "'undefined'", "'object'", "'number'"}, "C",
|
||||
"**解析:** typeof null返回'object',这是JavaScript的一个历史遗留bug。", []string{"JavaScript", "类型"}, "初级", "JavaScript"},
|
||||
{"JavaScript中,哪个方法用于阻止事件冒泡?", []string{"preventDefault()", "stopPropagation()", "stopImmediatePropagation()", "cancelBubble()"}, "B",
|
||||
"**解析:** stopPropagation()方法阻止事件继续传播到父元素。", []string{"JavaScript", "事件"}, "初级", "JavaScript"},
|
||||
{"CSS中,哪个选择器的优先级最高?", []string{"类选择器", "ID选择器", "标签选择器", "伪类选择器"}, "B",
|
||||
"**解析:** ID选择器的优先级最高,其次是类选择器和伪类选择器,最后是标签选择器。", []string{"CSS", "选择器"}, "初级", "CSS"},
|
||||
{"HTML中,哪个属性用于指定表单提交的URL?", []string{"method", "action", "enctype", "target"}, "B",
|
||||
"**解析:** action属性指定表单提交的目标URL。", []string{"HTML", "表单"}, "入门", "HTML"},
|
||||
{"JavaScript中,Promise的三种状态不包括?", []string{"pending", "resolved", "rejected", "fulfilled"}, "B",
|
||||
"**解析:** Promise的三种状态是pending(等待中)、fulfilled(已成功)、rejected(已失败)。resolved不是标准状态。", []string{"JavaScript", "Promise"}, "中级", "JavaScript"},
|
||||
}
|
||||
|
||||
fillQuestions := []questionItem{
|
||||
{"Go语言中,___关键字用于启动一个goroutine。", nil, "go",
|
||||
"**解析:** go关键字用于启动goroutine,它会在新的goroutine中执行指定的函数。", []string{"Go", "goroutine"}, "入门", "Go基础"},
|
||||
{"Go语言的包管理工具是___。", nil, "go mod",
|
||||
"**解析:** go mod是Go 1.11引入的官方包管理工具,用于管理项目依赖。", []string{"Go", "包管理"}, "入门", "Go基础"},
|
||||
{"HTTP状态码___表示请求成功。", nil, "200",
|
||||
"**解析:** HTTP 200 OK表示请求成功。", []string{"HTTP", "状态码"}, "入门", "网络协议"},
|
||||
{"JSON序列化使用的包是___。", nil, "encoding/json",
|
||||
"**解析:** encoding/json包提供了JSON序列化和反序列化功能。", []string{"Go", "JSON"}, "初级", "Go基础"},
|
||||
{"Go语言中,___关键字用于错误处理。", nil, "error",
|
||||
"**解析:** error是Go语言的错误类型,通常作为函数的返回值返回。", []string{"Go", "错误处理"}, "入门", "Go基础"},
|
||||
{"MySQL事务的ACID特性中,A代表___。", nil, "Atomicity/原子性",
|
||||
"**解析:** ACID分别代表:Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。", []string{"MySQL", "事务"}, "初级", "MySQL"},
|
||||
{"Redis中,___命令用于查看键的剩余过期时间。", nil, "TTL",
|
||||
"**解析:** TTL命令返回键的剩余生存时间(秒)。", []string{"Redis", "命令"}, "初级", "Redis"},
|
||||
{"TCP三次握手过程中,第二次握手服务器发送的报文包含SYN和___标志位。", nil, "ACK",
|
||||
"**解析:** 三次握手:1.客户端发送SYN;2.服务器回复SYN+ACK;3.客户端发送ACK。", []string{"TCP", "网络"}, "中级", "网络协议"},
|
||||
{"Go语言中,___函数用于创建切片、map和channel。", nil, "make",
|
||||
"**解析:** make函数专门用于创建slice、map和channel这三种引用类型。", []string{"Go", "make"}, "入门", "Go基础"},
|
||||
{"Go语言中,___函数用于分配内存并返回指针。", nil, "new",
|
||||
"**解析:** new函数分配内存,返回指向该类型零值的指针。", []string{"Go", "new"}, "入门", "Go基础"},
|
||||
{"MySQL中,___约束确保列中的值唯一。", nil, "UNIQUE",
|
||||
"**解析:** UNIQUE约束确保列中的所有值都是不同的。", []string{"MySQL", "约束"}, "初级", "MySQL"},
|
||||
{"Redis中,___数据结构适合实现消息队列。", nil, "List",
|
||||
"**解析:** List(列表)支持先进先出(FIFO)操作,适合实现消息队列。", []string{"Redis", "数据结构"}, "初级", "Redis"},
|
||||
{"Docker中,___命令用于构建镜像。", nil, "docker build",
|
||||
"**解析:** docker build命令根据Dockerfile构建Docker镜像。", []string{"Docker", "镜像"}, "初级", "DevOps"},
|
||||
{"Kubernetes中,___用于管理Pod的副本数。", nil, "ReplicaSet",
|
||||
"**解析:** ReplicaSet确保指定数量的Pod副本在运行。", []string{"Kubernetes", "ReplicaSet"}, "初级", "DevOps"},
|
||||
{"HTTP状态码___表示服务器内部错误。", nil, "500",
|
||||
"**解析:** HTTP 500 Internal Server Error表示服务器遇到了意外情况。", []string{"HTTP", "状态码"}, "入门", "网络协议"},
|
||||
{"Git中,___命令用于创建新分支。", nil, "git branch",
|
||||
"**解析:** git branch命令用于创建、列出和删除分支。", []string{"Git", "分支"}, "入门", "工具"},
|
||||
{"HTML中,___标签用于定义表格的表头单元格。", nil, "th",
|
||||
"**解析:** <th>标签定义表头单元格,通常显示为粗体居中。", []string{"HTML", "表格"}, "入门", "HTML"},
|
||||
{"CSS中,___属性用于设置元素的背景颜色。", nil, "background-color",
|
||||
"**解析:** background-color属性设置元素的背景颜色。", []string{"CSS", "背景"}, "入门", "CSS"},
|
||||
{"JavaScript中,___关键字用于声明常量。", nil, "const",
|
||||
"**解析:** const声明的常量不能重新赋值。", []string{"JavaScript", "变量"}, "入门", "JavaScript"},
|
||||
{"HTML5中,___标签用于播放视频。", nil, "video",
|
||||
"**解析:** <video>标签定义视频播放器。", []string{"HTML5", "媒体"}, "入门", "HTML"},
|
||||
{"CSS中,___单位是相对于根元素字体大小的。", nil, "rem",
|
||||
"**解析:** rem(root em)是相对于<html>元素的字体大小。", []string{"CSS", "单位"}, "初级", "CSS"},
|
||||
}
|
||||
|
||||
saQuestions := []questionItem{
|
||||
{"请简述Go语言的并发模型。", nil,
|
||||
"Go语言采用goroutine和channel实现并发。goroutine是轻量级线程,由Go运行时管理。channel用于goroutine之间的通信,支持同步和异步模式。select语句可以同时等待多个channel操作。",
|
||||
"**深入解析:**\n\n**1. Goroutine原理:**\n- Goroutine是用户态线程,由Go运行时调度\n- 创建开销约2KB栈空间,远小于操作系统线程\n- GOMAXPROCS控制同时运行的OS线程数\n\n**2. Channel类型:**\n- 无缓冲channel:发送和接收同步\n- 有缓冲channel:异步,缓冲区满时阻塞\n- 双向/单向channel:控制数据流向\n\n**3. 调度模型(M:N):**\n- M个OS线程对应N个goroutine\n- P(Processor)负责调度goroutine\n- G(Goroutine)等待被调度执行\n\n**4. 并发同步原语:**\n- sync.Mutex/RWMutex:互斥锁\n- sync.WaitGroup:等待多个goroutine完成\n- sync.Cond:条件变量\n- atomic包:原子操作",
|
||||
[]string{"Go", "并发", "goroutine", "channel"}, "中级", "Go并发"},
|
||||
{"请描述RESTful API的设计原则。", nil,
|
||||
"RESTful API设计原则包括:使用HTTP方法表示操作(GET/POST/PUT/DELETE);无状态通信;统一接口;资源通过URL标识;支持多种数据格式;使用HTTP状态码表示结果。",
|
||||
"**深入解析:**\n\n**1. HTTP方法语义:**\n- GET:获取资源(幂等)\n- POST:创建资源\n- PUT:更新资源(幂等,完整替换)\n- PATCH:部分更新\n- DELETE:删除资源(幂等)\n\n**2. 无状态原则:**\n- 服务器不存储客户端状态\n- 每次请求包含所有必要信息\n- 便于水平扩展和缓存\n\n**3. 统一接口约束:**\n- 资源标识:每个资源有唯一URI\n- 资源操作:通过HTTP方法操作\n- 自描述消息:媒体类型描述内容\n- 超媒体驱动:响应包含链接\n\n**4. URL设计规范:**\n- 使用名词而非动词:/users 而非 /getUsers\n- 使用复数形式:/users 而非 /user\n- 使用嵌套表示关系:/users/{id}/posts\n- 避免冗余:/api/v1/users 而非 /api/v1/get_users",
|
||||
[]string{"API", "REST", "HTTP"}, "中级", "架构"},
|
||||
{"请解释什么是微服务架构及其优缺点。", nil,
|
||||
"微服务架构是将应用拆分为多个独立的小型服务。优点:独立部署、技术栈灵活、高可用、团队自治。缺点:分布式复杂性、服务间通信开销、数据一致性挑战、运维成本增加。",
|
||||
"**深入解析:**\n\n**1. 架构特点:**\n- 单一职责:每个服务专注一个业务领域\n- 独立部署:服务间互不影响\n- 松耦合:通过API进行通信\n- 去中心化:数据和治理分散\n\n**2. 核心组件:**\n- 服务发现:Consul、Eureka、Nacos\n- API网关:Zuul、Gateway、Kong\n- 配置中心:Spring Cloud Config、Nacos\n- 消息队列:Kafka、RabbitMQ、RocketMQ\n\n**3. 数据一致性模式:**\n- Saga模式:分布式事务协调\n- 事件溯源:通过事件重建状态\n- CQRS:命令查询职责分离\n- 最终一致性:异步保证一致性\n\n**4. 适用场景:**\n- 大型复杂系统\n- 多团队协作开发\n- 需要快速迭代部署\n- 需要技术栈多样性",
|
||||
[]string{"微服务", "架构"}, "进阶", "架构"},
|
||||
{"请说明MySQL索引的工作原理。", nil,
|
||||
"MySQL索引主要使用B-Tree结构。索引通过排序和分层存储实现快速查找。查询时从根节点开始,通过比较键值向下遍历到叶子节点,找到对应的数据行。索引可以显著提升查询性能,但会降低写入性能。",
|
||||
"**深入解析:**\n\n**1. B-Tree结构:**\n- 平衡树,所有叶子节点在同一层\n- 每个节点存储多个键值\n- 节点内有序,便于二分查找\n\n**2. B+Tree优化:**\n- 叶子节点存储数据或指向数据的指针\n- 叶子节点形成双向链表\n- 支持范围查询和全表扫描\n\n**3. 索引类型:**\n- 主键索引:唯一且非空\n- 唯一索引:值唯一但可空\n- 普通索引:无限制\n- 联合索引:多列组合\n- 全文索引:文本搜索\n\n**4. 索引失效场景:**\n- 使用!=或NOT IN\n- 使用函数或表达式\n- 使用LIKE '%xxx'\n- 类型不匹配\n- OR条件未全部命中索引\n\n**5. 覆盖索引:**\n- 查询所需字段都在索引中\n- 无需回表查询\n- 显著提升性能",
|
||||
[]string{"MySQL", "索引", "B-Tree"}, "进阶", "MySQL"},
|
||||
{"请描述Redis的持久化机制。", nil,
|
||||
"Redis提供两种持久化方式:RDB(快照)和AOF(追加日志)。RDB定期将内存数据写入磁盘,适合备份和灾难恢复。AOF记录所有写操作,重启时重放恢复数据,数据安全性更高。",
|
||||
"**深入解析:**\n\n**1. RDB持久化:**\n- 触发方式:定时自动、手动SAVE/BGSAVE\n- 优点:文件体积小、恢复速度快\n- 缺点:可能丢失最近数据\n- 适用场景:备份、灾难恢复\n\n**2. AOF持久化:**\n- 记录所有写命令到日志\n- 同步策略:always/everysec/no\n- 重写机制:压缩日志文件\n- 优点:数据安全,最多丢失1秒数据\n- 缺点:文件体积大、恢复慢\n\n**3. 混合持久化(Redis 4.0+):**\n- RDB作为基础快照\n- AOF记录增量变化\n- 兼顾速度和安全性\n\n**4. 恢复策略:**\n- 优先加载AOF\n- AOF不存在时加载RDB\n- 同时开启时混合加载",
|
||||
[]string{"Redis", "持久化", "RDB", "AOF"}, "进阶", "Redis"},
|
||||
{"请说明Go语言中interface的实现原理。", nil,
|
||||
"Go语言的interface采用鸭子类型,不需要显式声明实现。底层使用iface结构存储类型信息和数据指针。空接口interface{}可以存储任何类型的值。",
|
||||
"**深入解析:**\n\n**1. iface结构:**\n- tab:指向itab,包含类型信息和方法表\n- data:指向实际数据的指针\n\n**2. itab结构:**\n- inter:接口类型信息\n- _type:实际类型信息\n- fun:方法指针数组\n\n**3. 类型断言:**\n- 安全断言:value, ok := x.(Type)\n- 类型选择:switch v := x.(type) {}\n\n**4. 性能优化:**\n- 避免频繁类型断言\n- 优先使用具体类型而非接口",
|
||||
[]string{"Go", "接口", "底层原理"}, "高级", "Go基础"},
|
||||
{"请描述分布式系统中的CAP理论。", nil,
|
||||
"CAP理论指出分布式系统不可能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)三个特性。在网络分区发生时,必须在一致性和可用性之间做出选择。",
|
||||
"**深入解析:**\n\n**1. CAP三要素:**\n- C:一致性,所有节点看到相同的数据\n- A:可用性,任何请求都能获得响应\n- P:分区容错性,网络分区时系统继续运行\n\n**2. 权衡选择:**\n- CP:牺牲可用性保证一致性(如ZooKeeper)\n- AP:牺牲一致性保证可用性(如Redis集群)\n\n**3. BASE理论:**\n- 基本可用(Basically Available)\n- 软状态(Soft State)\n- 最终一致性(Eventually Consistent)",
|
||||
[]string{"分布式", "CAP", "理论"}, "高级", "架构"},
|
||||
}
|
||||
|
||||
algoQuestions := []questionItem{
|
||||
{"实现一个函数,反转单链表。要求时间复杂度O(n),空间复杂度O(1)。", nil,
|
||||
"```go\nfunc reverseList(head *ListNode) *ListNode {\n var prev *ListNode\n curr := head\n for curr != nil {\n nextTemp := curr.Next\n curr.Next = prev\n prev = curr\n curr = nextTemp\n }\n return prev\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思路:**\n- 使用三个指针:prev、curr、nextTemp\n- 遍历链表,逐个反转节点指向\n- 时间复杂度:O(n)\n- 空间复杂度:O(1)\n\n**2. 递归解法:**\n```go\nfunc reverseList(head *ListNode) *ListNode {\n if head == nil || head.Next == nil {\n return head\n }\n p := reverseList(head.Next)\n head.Next.Next = head\n head.Next = nil\n return p\n}\n```\n\n**3. 关键点:**\n- 保存下一节点的引用\n- 防止链表断裂\n- 返回新的头节点\n\n**4. 扩展问题:**\n- 反转链表前N个节点\n- 反转链表的一部分\n- K个一组反转链表",
|
||||
[]string{"算法", "链表", "反转"}, "初级", "算法"},
|
||||
{"实现一个函数,判断两个字符串是否是异位词。", nil,
|
||||
"```go\nfunc isAnagram(s, t string) bool {\n if len(s) != len(t) {\n return false\n }\n count := make([]int, 26)\n for i := 0; i < len(s); i++ {\n count[s[i]-'a']++\n count[t[i]-'a']--\n }\n for _, c := range count {\n if c != 0 {\n return false\n }\n }\n return true\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思路:**\n- 统计字符频率\n- 使用数组或哈希表\n- 比较两个字符串的频率\n\n**2. 复杂度分析:**\n- 时间:O(n)\n- 空间:O(1)(固定大小数组)\n\n**3. 扩展情况:**\n- 包含Unicode字符:使用map[rune]int\n- 大小写敏感/不敏感处理\n- 包含空格或特殊字符\n\n**4. 其他解法:**\n- 排序后比较:O(n log n)时间,O(1)空间\n- 使用哈希表:通用但空间稍大\n\n**5. 相关问题:**\n- 找到字符串中第一个不重复的字符\n- 最长无重复子串\n- 字符串排列",
|
||||
[]string{"算法", "字符串", "哈希表"}, "入门", "算法"},
|
||||
{"实现快速排序算法。要求平均时间复杂度O(n log n)。", nil,
|
||||
"```go\nfunc quickSort(nums []int) []int {\n if len(nums) <= 1 {\n return nums\n }\n pivot := nums[len(nums)/2]\n var left, right, middle []int\n for _, num := range nums {\n if num < pivot {\n left = append(left, num)\n } else if num > pivot {\n right = append(right, num)\n } else {\n middle = append(middle, num)\n }\n }\n return append(append(quickSort(left), middle...), quickSort(right)...)\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思想:**\n- 分治策略\n- 选择基准元素\n- 分区:小于/等于/大于基准\n- 递归处理左右分区\n\n**2. 复杂度分析:**\n- 平均时间:O(n log n)\n- 最坏时间:O(n²)(已排序数组)\n- 空间复杂度:O(log n)(递归栈)\n\n**3. 优化策略:**\n- 随机选择基准\n- 三数取中选择基准\n- 小数组使用插入排序\n- 尾递归优化\n\n**4. 原地排序实现:**\n```go\nfunc quickSortInPlace(nums []int, low, high int) {\n if low < high {\n pivot := partition(nums, low, high)\n quickSortInPlace(nums, low, pivot-1)\n quickSortInPlace(nums, pivot+1, high)\n }\n}\n```",
|
||||
[]string{"算法", "排序", "快速排序"}, "中级", "算法"},
|
||||
{"实现一个LRU缓存。要求get和put操作时间复杂度O(1)。", nil,
|
||||
"```go\ntype LRUCache struct {\n capacity int\n cache map[int]*Node\n head *Node\n tail *Node\n}\n\ntype Node struct {\n key, val int\n prev, next *Node\n}\n\nfunc (c *LRUCache) Get(key int) int {\n if node, ok := c.cache[key]; ok {\n c.remove(node)\n c.addToFront(node)\n return node.val\n }\n return -1\n}\n\nfunc (c *LRUCache) Put(key, value int) {\n if node, ok := c.cache[key]; ok {\n node.val = value\n c.remove(node)\n c.addToFront(node)\n return\n }\n if len(c.cache) == c.capacity {\n delete(c.cache, c.tail.key)\n c.remove(c.tail)\n }\n newNode := &Node{key: key, val: value}\n c.cache[key] = newNode\n c.addToFront(newNode)\n}\n```",
|
||||
"**深入解析:**\n\n**1. 数据结构:**\n- 哈希表:O(1)查找\n- 双向链表:O(1)删除和插入\n- 链表头部:最近使用\n- 链表尾部:最久未使用\n\n**2. 核心操作:**\n- Get:查找并移到头部\n- Put:插入头部,满时删除尾部\n- Remove:从链表中移除\n- AddToFront:添加到头部\n\n**3. 复杂度分析:**\n- Get:O(1)\n- Put:O(1)\n- 空间:O(capacity)\n\n**4. 扩展:LFU缓存**\n- 最少使用频率淘汰\n- 需要维护频率计数\n- 同频率按LRU淘汰",
|
||||
[]string{"算法", "缓存", "LRU"}, "进阶", "算法"},
|
||||
{"实现二叉树的层序遍历。", nil,
|
||||
"```go\nfunc levelOrder(root *TreeNode) [][]int {\n var result [][]int\n if root == nil {\n return result\n }\n queue := []*TreeNode{root}\n for len(queue) > 0 {\n levelSize := len(queue)\n var currentLevel []int\n for i := 0; i < levelSize; i++ {\n node := queue[0]\n queue = queue[1:]\n currentLevel = append(currentLevel, node.Val)\n if node.Left != nil {\n queue = append(queue, node.Left)\n }\n if node.Right != nil {\n queue = append(queue, node.Right)\n }\n }\n result = append(result, currentLevel)\n }\n return result\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思路:**\n- 使用队列进行广度优先遍历\n- 按层处理节点\n- 记录每层节点数\n- 逐层收集结果\n\n**2. 复杂度分析:**\n- 时间:O(n),每个节点访问一次\n- 空间:O(n),最坏情况队列存储所有节点\n\n**3. 变体问题:**\n- 锯齿形层序遍历\n- 层序遍历从底向上\n- 每层最大值\n- 二叉树的右视图\n\n**4. 递归实现:**\n```go\nfunc levelOrderRecursive(root *TreeNode) [][]int {\n var result [][]int\n dfs(root, 0, &result)\n return result\n}\n```",
|
||||
[]string{"算法", "二叉树", "层序遍历"}, "初级", "算法"},
|
||||
{"实现二分查找算法。", nil,
|
||||
"```go\nfunc binarySearch(nums []int, target int) int {\n left, right := 0, len(nums)-1\n for left <= right {\n mid := left + (right-left)/2\n if nums[mid] == target {\n return mid\n } else if nums[mid] < target {\n left = mid + 1\n } else {\n right = mid - 1\n }\n }\n return -1\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思想:**\n- 分治策略\n- 有序数组前提\n- 每次排除一半元素\n\n**2. 复杂度分析:**\n- 时间:O(log n)\n- 空间:O(1)\n\n**3. 边界处理:**\n- 避免整数溢出:mid = left + (right-left)/2\n- 循环条件:left <= right\n\n**4. 变体问题:**\n- 查找第一个等于target的元素\n- 查找最后一个等于target的元素\n- 查找第一个大于等于target的元素",
|
||||
[]string{"算法", "二分查找"}, "入门", "算法"},
|
||||
{"实现合并两个有序链表。", nil,
|
||||
"```go\nfunc mergeTwoLists(l1, l2 *ListNode) *ListNode {\n dummy := &ListNode{}\n curr := dummy\n for l1 != nil && l2 != nil {\n if l1.Val < l2.Val {\n curr.Next = l1\n l1 = l1.Next\n } else {\n curr.Next = l2\n l2 = l2.Next\n }\n curr = curr.Next\n }\n if l1 != nil {\n curr.Next = l1\n } else {\n curr.Next = l2\n }\n return dummy.Next\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思路:**\n- 虚拟头节点简化边界处理\n- 双指针遍历两个链表\n- 比较节点值,选择较小者\n\n**2. 复杂度分析:**\n- 时间:O(n+m)\n- 空间:O(1)\n\n**3. 递归解法:**\n```go\nfunc mergeTwoLists(l1, l2 *ListNode) *ListNode {\n if l1 == nil { return l2 }\n if l2 == nil { return l1 }\n if l1.Val < l2.Val {\n l1.Next = mergeTwoLists(l1.Next, l2)\n return l1\n }\n l2.Next = mergeTwoLists(l1, l2.Next)\n return l2\n}\n```",
|
||||
[]string{"算法", "链表", "合并"}, "初级", "算法"},
|
||||
}
|
||||
|
||||
shuffleSlice := func(slice interface{}) {
|
||||
switch s := slice.(type) {
|
||||
case []questionItem:
|
||||
for i := len(s) - 1; i > 0; i-- {
|
||||
j := rand.Intn(i + 1)
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if containsType(selectedTypes, "mcq") {
|
||||
mcqFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(mcqQuestions, techKeywords), difficultyLevel)
|
||||
shuffleSlice(mcqFiltered)
|
||||
for i := 0; i < 5 && i < len(mcqFiltered); i++ {
|
||||
mcq := mcqFiltered[i]
|
||||
questions = append(questions, map[string]interface{}{
|
||||
"type": "mcq",
|
||||
"text": mcq.question,
|
||||
"options": mcq.options,
|
||||
"answer": mcq.answer,
|
||||
"analysis": mcq.analysis,
|
||||
"score": getScoreByDifficulty(mcq.difficulty),
|
||||
"keywords": mcq.keywords,
|
||||
"difficulty": mcq.difficulty,
|
||||
"category": mcq.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if containsType(selectedTypes, "fill") {
|
||||
fillFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(fillQuestions, techKeywords), difficultyLevel)
|
||||
shuffleSlice(fillFiltered)
|
||||
for i := 0; i < 5 && i < len(fillFiltered); i++ {
|
||||
fill := fillFiltered[i]
|
||||
questions = append(questions, map[string]interface{}{
|
||||
"type": "fill",
|
||||
"text": fill.question,
|
||||
"answer": fill.answer,
|
||||
"analysis": fill.analysis,
|
||||
"score": getScoreByDifficulty(fill.difficulty),
|
||||
"keywords": fill.keywords,
|
||||
"difficulty": fill.difficulty,
|
||||
"category": fill.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if containsType(selectedTypes, "sa") {
|
||||
saFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(saQuestions, techKeywords), difficultyLevel)
|
||||
shuffleSlice(saFiltered)
|
||||
for i := 0; i < 5 && i < len(saFiltered); i++ {
|
||||
sa := saFiltered[i]
|
||||
questions = append(questions, map[string]interface{}{
|
||||
"type": "sa",
|
||||
"text": sa.question,
|
||||
"answer": sa.answer,
|
||||
"analysis": sa.analysis,
|
||||
"score": getScoreByDifficulty(sa.difficulty),
|
||||
"keywords": sa.keywords,
|
||||
"difficulty": sa.difficulty,
|
||||
"category": sa.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if containsType(selectedTypes, "algo") {
|
||||
algoFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(algoQuestions, techKeywords), difficultyLevel)
|
||||
shuffleSlice(algoFiltered)
|
||||
for i := 0; i < 5 && i < len(algoFiltered); i++ {
|
||||
algo := algoFiltered[i]
|
||||
questions = append(questions, map[string]interface{}{
|
||||
"type": "algo",
|
||||
"text": algo.question,
|
||||
"answer": algo.answer,
|
||||
"analysis": algo.analysis,
|
||||
"score": getScoreByDifficulty(algo.difficulty),
|
||||
"keywords": algo.keywords,
|
||||
"difficulty": algo.difficulty,
|
||||
"category": algo.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
func getScoreByDifficulty(difficulty string) int {
|
||||
switch difficulty {
|
||||
case "入门":
|
||||
return 5
|
||||
case "初级":
|
||||
return 10
|
||||
case "中级":
|
||||
return 15
|
||||
case "进阶":
|
||||
return 20
|
||||
case "高级":
|
||||
return 25
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
|
||||
func parseTypes(typesStr string) []string {
|
||||
if typesStr == "" {
|
||||
return []string{"mcq", "fill", "sa", "algo"}
|
||||
}
|
||||
return strings.Split(typesStr, ",")
|
||||
}
|
||||
|
||||
func containsType(types []string, t string) bool {
|
||||
for _, tp := range types {
|
||||
if tp == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type questionItem struct {
|
||||
question string
|
||||
options []string
|
||||
answer string
|
||||
analysis string
|
||||
keywords []string
|
||||
difficulty string
|
||||
category string
|
||||
}
|
||||
|
||||
func extractPosition(resumeText string, keywords []string) string {
|
||||
positionKeywords := map[string][]string{
|
||||
"高级工程师": {"高级", "资深", "architect", "架构师", "技术负责人", "tech lead", "lead"},
|
||||
"中级工程师": {"中级", "工程师", "developer", "software engineer", "全栈"},
|
||||
"初级工程师": {"初级", "助理", "实习生", "junior", "graduate", "entry"},
|
||||
}
|
||||
|
||||
resumeLower := strings.ToLower(resumeText)
|
||||
|
||||
for position, keywords := range positionKeywords {
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(resumeLower, strings.ToLower(kw)) {
|
||||
return position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(strings.ToLower(kw), "架构") || strings.Contains(strings.ToLower(kw), "架构师") {
|
||||
return "高级工程师"
|
||||
}
|
||||
}
|
||||
|
||||
return "中级工程师"
|
||||
}
|
||||
|
||||
func getDifficultyByPosition(position string) string {
|
||||
switch position {
|
||||
case "高级工程师":
|
||||
return "高级"
|
||||
case "中级工程师":
|
||||
return "中级"
|
||||
case "初级工程师":
|
||||
return "初级"
|
||||
default:
|
||||
return "中级"
|
||||
}
|
||||
}
|
||||
|
||||
func filterQuestionsByDifficulty(questions []questionItem, minDifficulty string) []questionItem {
|
||||
difficultyOrder := map[string]int{
|
||||
"入门": 0,
|
||||
"初级": 1,
|
||||
"中级": 2,
|
||||
"进阶": 3,
|
||||
"高级": 4,
|
||||
}
|
||||
|
||||
minLevel := difficultyOrder[minDifficulty]
|
||||
var filtered []questionItem
|
||||
|
||||
for _, q := range questions {
|
||||
if difficultyOrder[q.difficulty] >= minLevel {
|
||||
filtered = append(filtered, q)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return questions
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// keywordAliases 关键词别名映射表,扩展用户输入的关键词以提高匹配命中率
|
||||
// 例如:用户输入 "React" 时,同时匹配 "前端"、"JavaScript" 等相关关键词
|
||||
var keywordAliases = map[string][]string{
|
||||
"go": {"go", "golang", "goroutine", "channel", "defer", "make", "const", "var", "interface", "gomaxprocs", "panic", "recover", "new"},
|
||||
"golang": {"go", "golang", "goroutine", "channel", "defer", "make", "interface"},
|
||||
"java": {"java", "jvm", "spring", "maven", "gradle", "jdk", "jre"},
|
||||
"python": {"python", "django", "flask", "pandas", "numpy"},
|
||||
"javascript": {"javascript", "js", "es6", "promise", "async", "await"},
|
||||
"typescript": {"typescript", "ts", "javascript", "js"},
|
||||
"react": {"react", "jsx", "tsx", "hooks", "redux", "前端", "javascript"},
|
||||
"vue": {"vue", "vuex", "vuejs", "前端", "javascript"},
|
||||
"mysql": {"mysql", "sql", "数据库", "innodb", "事务", "索引", "acid"},
|
||||
"redis": {"redis", "缓存", "rdb", "aof", "sorted set", "ttl"},
|
||||
"docker": {"docker", "容器", "镜像", "dockerfile"},
|
||||
"kubernetes": {"kubernetes", "k8s", "pod", "replicaset", "container"},
|
||||
"k8s": {"kubernetes", "k8s", "pod", "replicaset"},
|
||||
"微服务": {"微服务", "架构", "rest", "api", "服务发现", "分布式"},
|
||||
"分布式": {"分布式", "cap", "微服务", "一致性", "raft", "paxos"},
|
||||
"网络": {"网络", "tcp", "http", "https", "tcp/ip", "osi"},
|
||||
"http": {"http", "https", "rest", "api", "状态码"},
|
||||
"https": {"https", "http", "ssl", "tls"},
|
||||
"算法": {"算法", "链表", "二叉树", "排序", "查找", "二分查找", "快速排序", "lru"},
|
||||
"html": {"html", "html5", "前端", "表单"},
|
||||
"html5": {"html5", "html", "前端"},
|
||||
"css": {"css", "flexbox", "rem", "选择器", "前端"},
|
||||
"git": {"git", "版本控制", "分支"},
|
||||
"api": {"api", "rest", "restful", "接口"},
|
||||
"rest": {"rest", "restful", "api", "http"},
|
||||
"tcp": {"tcp", "网络", "osi", "三次握手"},
|
||||
}
|
||||
|
||||
// expandKeywords 扩展关键词列表,加入别名
|
||||
func expandKeywords(keywords []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var expanded []string
|
||||
for _, kw := range keywords {
|
||||
kwLower := strings.ToLower(strings.TrimSpace(kw))
|
||||
if kwLower == "" {
|
||||
continue
|
||||
}
|
||||
if !seen[kwLower] {
|
||||
seen[kwLower] = true
|
||||
expanded = append(expanded, kwLower)
|
||||
}
|
||||
if aliases, ok := keywordAliases[kwLower]; ok {
|
||||
for _, alias := range aliases {
|
||||
if !seen[alias] {
|
||||
seen[alias] = true
|
||||
expanded = append(expanded, alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func filterQuestionsByKeywords(questions []questionItem, keywords []string) []questionItem {
|
||||
if len(keywords) == 0 {
|
||||
return questions
|
||||
}
|
||||
|
||||
expandedKeywords := expandKeywords(keywords)
|
||||
if len(expandedKeywords) == 0 {
|
||||
return questions
|
||||
}
|
||||
|
||||
var filtered []questionItem
|
||||
for _, q := range questions {
|
||||
matched := false
|
||||
for _, qkw := range q.keywords {
|
||||
qkwLower := strings.ToLower(qkw)
|
||||
for _, kw := range expandedKeywords {
|
||||
if qkwLower == kw || strings.Contains(qkwLower, kw) || strings.Contains(kw, qkwLower) {
|
||||
filtered = append(filtered, q)
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 严格过滤:未匹配时返回空切片,由调用方处理(GenerateQuestions 会返回"未找到与关键词匹配的题目")
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (t *KeywordExtractTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: "extract_keywords",
|
||||
Desc: "从简历文本中提取关键词,包括技术栈、技能、行业术语等。输入简历文本,返回结构化的关键词列表。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"text": {
|
||||
Desc: "简历文本内容",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
},
|
||||
"category": {
|
||||
Desc: "提取类别:all(全部)、tech(技术栈)、skill(技能)、industry(行业术语)、soft(软技能)",
|
||||
Type: schema.String,
|
||||
Enum: []string{"all", "tech", "skill", "industry", "soft"},
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *KeywordExtractTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
Text string `json:"text"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Text == "" {
|
||||
return "", fmt.Errorf("text is required")
|
||||
}
|
||||
|
||||
if args.Category == "" {
|
||||
args.Category = "all"
|
||||
}
|
||||
|
||||
techStack := extractTechKeywords(args.Text)
|
||||
skills := extractSkillKeywords(args.Text)
|
||||
industryTerms := extractIndustryKeywords(args.Text)
|
||||
softSkills := extractSoftSkillKeywords(args.Text)
|
||||
|
||||
result := fmt.Sprintf(`{"tech_stack": %s, "skills": %s, "industry_terms": %s, "soft_skills": %s}`,
|
||||
toJSONArray(techStack),
|
||||
toJSONArray(skills),
|
||||
toJSONArray(industryTerms),
|
||||
toJSONArray(softSkills),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toJSONArray(items []string) string {
|
||||
if len(items) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
result := "["
|
||||
for i, item := range items {
|
||||
if i > 0 {
|
||||
result += ","
|
||||
}
|
||||
result += fmt.Sprintf(`"%s"`, escapeJSON(item))
|
||||
}
|
||||
result += "]"
|
||||
return result
|
||||
}
|
||||
|
||||
func extractTechKeywords(text string) []string {
|
||||
techPatterns := []string{"Go", "Golang", "Java", "Python", "JavaScript", "TypeScript", "React", "Vue", "MySQL", "PostgreSQL", "Redis", "MongoDB", "Kafka", "Docker", "Kubernetes", "微服务", "分布式"}
|
||||
return extractKeywords(text, techPatterns)
|
||||
}
|
||||
|
||||
func extractSkillKeywords(text string) []string {
|
||||
skillPatterns := []string{"开发", "设计", "架构", "优化", "重构", "测试", "部署", "运维", "监控", "调优"}
|
||||
return extractKeywords(text, skillPatterns)
|
||||
}
|
||||
|
||||
func extractIndustryKeywords(text string) []string {
|
||||
industryPatterns := []string{"互联网", "金融", "电商", "医疗", "教育", "云计算", "大数据", "人工智能", "物联网"}
|
||||
return extractKeywords(text, industryPatterns)
|
||||
}
|
||||
|
||||
func extractSoftSkillKeywords(text string) []string {
|
||||
softPatterns := []string{"沟通", "团队协作", "项目管理", "问题解决", "创新", "学习能力", "抗压能力", "责任心"}
|
||||
return extractKeywords(text, softPatterns)
|
||||
}
|
||||
|
||||
func extractKeywords(text string, patterns []string) []string {
|
||||
var result []string
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(text, pattern) {
|
||||
result = append(result, pattern)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeepSeekProvider DeepSeek AI 供应商实现
|
||||
type DeepSeekProvider struct {
|
||||
cfg *config.DeepSeekConfig // DeepSeek 配置
|
||||
httpClient *http.Client // HTTP 客户端
|
||||
}
|
||||
|
||||
// NewDeepSeekProvider 创建 DeepSeek 供应商实例
|
||||
// @param cfg DeepSeek 配置
|
||||
// @return *DeepSeekProvider 供应商实例
|
||||
// @author sunct
|
||||
func NewDeepSeekProvider(cfg *config.DeepSeekConfig) *DeepSeekProvider {
|
||||
return &DeepSeekProvider{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetName 获取供应商名称
|
||||
// @return string 供应商标识
|
||||
// @author sunct
|
||||
func (p *DeepSeekProvider) GetName() string {
|
||||
return "deepseek"
|
||||
}
|
||||
|
||||
// Generate 调用 DeepSeek API 生成文本
|
||||
// @param ctx 上下文
|
||||
// @param prompt 提示词
|
||||
// @param opts 可选参数
|
||||
// @return string 生成结果
|
||||
// @return error 调用错误
|
||||
// @author sunct
|
||||
func (p *DeepSeekProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
options := &Options{
|
||||
MaxTokens: p.cfg.MaxTokens,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": p.getModelName(),
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
"max_tokens": options.MaxTokens,
|
||||
"temperature": options.Temperature,
|
||||
"top_p": options.TopP,
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := message["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
// getModelName 获取模型名称,未配置时使用默认 deepseek-chat
|
||||
// @return string 模型名
|
||||
// @author sunct
|
||||
func (p *DeepSeekProvider) getModelName() string {
|
||||
if p.cfg.Model != "" {
|
||||
return p.cfg.Model
|
||||
}
|
||||
return "deepseek-chat"
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DoubaoProvider 豆包 AI 供应商实现
|
||||
type DoubaoProvider struct {
|
||||
cfg *config.DoubaoConfig // 豆包配置
|
||||
httpClient *http.Client // HTTP 客户端
|
||||
}
|
||||
|
||||
// NewDoubaoProvider 创建豆包供应商实例
|
||||
// @param cfg 豆包配置
|
||||
// @return *DoubaoProvider 供应商实例
|
||||
// @author sunct
|
||||
func NewDoubaoProvider(cfg *config.DoubaoConfig) *DoubaoProvider {
|
||||
return &DoubaoProvider{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetName 获取供应商名称
|
||||
// @return string 供应商标识
|
||||
// @author sunct
|
||||
func (p *DoubaoProvider) GetName() string {
|
||||
return "doubao"
|
||||
}
|
||||
|
||||
// Generate 调用豆包 API 生成文本
|
||||
// @param ctx 上下文
|
||||
// @param prompt 提示词
|
||||
// @param opts 可选参数
|
||||
// @return string 生成结果
|
||||
// @return error 调用错误
|
||||
// @author sunct
|
||||
func (p *DoubaoProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
options := &Options{
|
||||
MaxTokens: p.cfg.MaxTokens,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": p.getModelName(),
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
"max_tokens": options.MaxTokens,
|
||||
"temperature": options.Temperature,
|
||||
"top_p": options.TopP,
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := message["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
// getModelName 获取模型名称,未配置时使用默认 Doubao
|
||||
// @return string 模型名
|
||||
// @author sunct
|
||||
func (p *DoubaoProvider) getModelName() string {
|
||||
if p.cfg.Model != "" {
|
||||
return p.cfg.Model
|
||||
}
|
||||
return "Doubao"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package ai AI 供应商适配层,定义统一的 LLM 调用接口
|
||||
// 支持通义千问、DeepSeek、豆包、讯飞星火等多家大模型
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/pkg/config"
|
||||
)
|
||||
|
||||
// Provider AI 服务提供者接口,所有大模型供应商需实现此接口
|
||||
type Provider interface {
|
||||
// Generate 调用大模型生成文本
|
||||
Generate(ctx context.Context, prompt string, opts ...Option) (string, error)
|
||||
// GetName 获取供应商名称
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// Option 调用选项函数类型,用于可选参数配置
|
||||
type Option func(*Options)
|
||||
|
||||
// Options AI 调用可选参数
|
||||
type Options struct {
|
||||
MaxTokens int // 最大生成token数
|
||||
Temperature float64 // 温度(随机性)
|
||||
TopP float64 // 核采样参数
|
||||
}
|
||||
|
||||
// WithMaxTokens 设置最大生成 token 数
|
||||
// @param maxTokens 最大token数
|
||||
// @return Option 选项函数
|
||||
// @author sunct
|
||||
func WithMaxTokens(maxTokens int) Option {
|
||||
return func(o *Options) {
|
||||
o.MaxTokens = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
// WithTemperature 设置生成温度
|
||||
// @param temp 温度值(0~1)
|
||||
// @return Option 选项函数
|
||||
// @author sunct
|
||||
func WithTemperature(temp float64) Option {
|
||||
return func(o *Options) {
|
||||
o.Temperature = temp
|
||||
}
|
||||
}
|
||||
|
||||
// WithTopP 设置核采样参数
|
||||
// @param topP 核采样值
|
||||
// @return Option 选项函数
|
||||
// @author sunct
|
||||
func WithTopP(topP float64) Option {
|
||||
return func(o *Options) {
|
||||
o.TopP = topP
|
||||
}
|
||||
}
|
||||
|
||||
// NewProvider AI 供应商工厂函数,根据配置创建对应供应商实例
|
||||
// @param cfg AI 配置
|
||||
// @return Provider 供应商实例
|
||||
// @return error 创建错误
|
||||
// @author sunct
|
||||
func NewProvider(cfg *config.AIConfig) (Provider, error) {
|
||||
switch cfg.Provider {
|
||||
case "spark":
|
||||
return NewSparkProvider(&cfg.Spark), nil
|
||||
case "tongyi":
|
||||
return NewTongyiProvider(&cfg.Tongyi), nil
|
||||
case "doubao":
|
||||
return NewDoubaoProvider(&cfg.Doubao), nil
|
||||
case "deepseek":
|
||||
return NewDeepSeekProvider(&cfg.DeepSeek), nil
|
||||
default:
|
||||
return NewSparkProvider(&cfg.Spark), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"resume-platform/pkg/config"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type SparkProvider struct {
|
||||
cfg *config.SparkConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewSparkProvider(cfg *config.SparkConfig) *SparkProvider {
|
||||
return &SparkProvider{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SparkProvider) GetName() string {
|
||||
return "spark"
|
||||
}
|
||||
|
||||
func (p *SparkProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
options := &Options{
|
||||
MaxTokens: p.cfg.MaxTokens,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
wsURL, err := p.buildAuthURL()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return p.sendWebSocketRequest(ctx, wsURL, prompt, options)
|
||||
}
|
||||
|
||||
func (p *SparkProvider) buildAuthURL() (string, error) {
|
||||
parsedURL, err := url.Parse(p.cfg.BaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
host := parsedURL.Host
|
||||
path := parsedURL.Path
|
||||
now := time.Now()
|
||||
date := now.UTC().Format(time.RFC1123)
|
||||
|
||||
signatureOrigin := fmt.Sprintf("host: %s\ndate: %s\nGET %s HTTP/1.1", host, date, path)
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(p.cfg.APISecret))
|
||||
mac.Write([]byte(signatureOrigin))
|
||||
signatureSha := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
authorizationOrigin := fmt.Sprintf(
|
||||
`api_key="%s", algorithm="%s", headers="%s", signature="%s"`,
|
||||
p.cfg.APIKey, "hmac-sha256", "host date request-line", signatureSha,
|
||||
)
|
||||
|
||||
authorization := base64.StdEncoding.EncodeToString([]byte(authorizationOrigin))
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("authorization", authorization)
|
||||
query.Set("date", date)
|
||||
query.Set("host", host)
|
||||
|
||||
return p.cfg.BaseURL + "?" + query.Encode(), nil
|
||||
}
|
||||
|
||||
func (p *SparkProvider) sendWebSocketRequest(ctx context.Context, wsURL string, prompt string, options *Options) (string, error) {
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to dial websocket: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
var result strings.Builder
|
||||
var lastErr error
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||
return
|
||||
}
|
||||
lastErr = fmt.Errorf("failed to read message: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(message, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if header, ok := resp["header"].(map[string]interface{}); ok {
|
||||
if code, ok := header["code"].(float64); ok && code != 0 {
|
||||
if msg, ok := header["message"].(string); ok {
|
||||
lastErr = fmt.Errorf("API error: %s (code: %d)", msg, int(code))
|
||||
} else {
|
||||
lastErr = fmt.Errorf("API error with code: %d", int(code))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if payload, ok := resp["payload"].(map[string]interface{}); ok {
|
||||
if choices, ok := payload["choices"].(map[string]interface{}); ok {
|
||||
if text, ok := choices["text"].([]interface{}); ok && len(text) > 0 {
|
||||
if item, ok := text[0].(map[string]interface{}); ok {
|
||||
if content, ok := item["content"].(string); ok {
|
||||
result.WriteString(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if status, ok := choices["status"].(float64); ok && status == 2 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"header": map[string]interface{}{
|
||||
"app_id": p.cfg.APPID,
|
||||
"uid": "resume-platform",
|
||||
},
|
||||
"parameter": map[string]interface{}{
|
||||
"chat": map[string]interface{}{
|
||||
"domain": p.getDomainName(),
|
||||
"temperature": options.Temperature,
|
||||
"max_tokens": options.MaxTokens,
|
||||
"top_k": 4,
|
||||
"web_search": p.cfg.WebSearch,
|
||||
"show_ref_label": p.cfg.ShowRefLabel,
|
||||
},
|
||||
},
|
||||
"payload": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"text": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, jsonBody); err != nil {
|
||||
return "", fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return result.String(), nil
|
||||
case <-ctx.Done():
|
||||
conn.Close()
|
||||
return "", ctx.Err()
|
||||
case <-time.After(time.Duration(p.cfg.Timeout) * time.Second):
|
||||
conn.Close()
|
||||
return "", fmt.Errorf("request timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SparkProvider) getDomainName() string {
|
||||
switch p.cfg.Model {
|
||||
case "lite":
|
||||
return "lite"
|
||||
case "pro":
|
||||
return "generalv3"
|
||||
case "pro-128k":
|
||||
return "pro-128k"
|
||||
case "max":
|
||||
return "generalv3.5"
|
||||
case "max-32k":
|
||||
return "max-32k"
|
||||
case "ultra":
|
||||
return "4.0Ultra"
|
||||
case "kjwx":
|
||||
return "kjwx"
|
||||
default:
|
||||
return "lite"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TongyiProvider 通义千问 AI 供应商实现
|
||||
type TongyiProvider struct {
|
||||
cfg *config.TongyiConfig // 通义千问配置
|
||||
httpClient *http.Client // HTTP 客户端
|
||||
}
|
||||
|
||||
// NewTongyiProvider 创建通义千问供应商实例
|
||||
// @param cfg 通义千问配置
|
||||
// @return *TongyiProvider 供应商实例
|
||||
// @author sunct
|
||||
func NewTongyiProvider(cfg *config.TongyiConfig) *TongyiProvider {
|
||||
return &TongyiProvider{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetName 获取供应商名称
|
||||
// @return string 供应商标识
|
||||
// @author sunct
|
||||
func (p *TongyiProvider) GetName() string {
|
||||
return "tongyi"
|
||||
}
|
||||
|
||||
// Generate 调用通义千问 API 生成文本
|
||||
// @param ctx 上下文
|
||||
// @param prompt 提示词
|
||||
// @param opts 可选参数
|
||||
// @return string 生成结果
|
||||
// @return error 调用错误
|
||||
// @author sunct
|
||||
func (p *TongyiProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
options := &Options{
|
||||
MaxTokens: p.cfg.MaxTokens,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": p.getModelName(),
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
"max_tokens": options.MaxTokens,
|
||||
"temperature": options.Temperature,
|
||||
"top_p": options.TopP,
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := message["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
// getModelName 获取模型名称,未配置时使用默认 qwen-turbo
|
||||
// @return string 模型名
|
||||
// @author sunct
|
||||
func (p *TongyiProvider) getModelName() string {
|
||||
if p.cfg.Model != "" {
|
||||
return p.cfg.Model
|
||||
}
|
||||
return "qwen-turbo"
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"resume-platform/internal/handler"
|
||||
"resume-platform/internal/router"
|
||||
"resume-platform/internal/security"
|
||||
"resume-platform/pkg/config"
|
||||
"resume-platform/pkg/logger"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context) error {
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if err := initLogger(cfg); err != nil {
|
||||
return fmt.Errorf("failed to initialize logger: %w", err)
|
||||
}
|
||||
|
||||
if err := initDataDir(); err != nil {
|
||||
return fmt.Errorf("failed to initialize data directory: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("Configuration loaded - AdminPath: %s, Mode: %s", cfg.Auth.AdminPath, cfg.Server.Mode)
|
||||
|
||||
container, err := NewServiceContainer(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize service container: %w", err)
|
||||
}
|
||||
|
||||
handler.SetResumeRepository(container.Repo)
|
||||
handler.LoadTokens()
|
||||
|
||||
if err := initSecurity(cfg); err != nil {
|
||||
return fmt.Errorf("failed to initialize security: %w", err)
|
||||
}
|
||||
|
||||
if err := container.InitResumeAgent(ctx, cfg); err != nil {
|
||||
logger.Warnf("Failed to initialize Resume Agent: %v", err)
|
||||
}
|
||||
|
||||
if err := startServer(ctx, cfg, container); err != nil {
|
||||
return fmt.Errorf("failed to start server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initLogger(cfg *config.Config) error {
|
||||
return logger.Init(logger.Config{
|
||||
Level: cfg.Log.Level,
|
||||
Output: cfg.Log.Output,
|
||||
})
|
||||
}
|
||||
|
||||
func initDataDir() error {
|
||||
return os.MkdirAll("./data", 0755)
|
||||
}
|
||||
|
||||
func initSecurity(cfg *config.Config) error {
|
||||
security.InitCaptcha(160, 50)
|
||||
return nil
|
||||
}
|
||||
|
||||
func startServer(ctx context.Context, cfg *config.Config, container *ServiceContainer) error {
|
||||
r := router.NewRouter(
|
||||
container.ResumeHandler,
|
||||
container.AdminHandler,
|
||||
container.AIHandler,
|
||||
container.DocumentHandler,
|
||||
container.ChatHandler,
|
||||
container.IMHandler,
|
||||
container.KnowledgeHandler,
|
||||
cfg,
|
||||
)
|
||||
engine := r.Setup()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
logger.Infof("Server starting on %s", addr)
|
||||
|
||||
return engine.Run(addr)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"resume-platform/internal/agent"
|
||||
aiprovider "resume-platform/internal/ai"
|
||||
"resume-platform/internal/handler"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/internal/security"
|
||||
"resume-platform/internal/service/chat"
|
||||
"resume-platform/internal/service/document"
|
||||
"resume-platform/internal/service/knowledge"
|
||||
"resume-platform/internal/service/portfolio"
|
||||
"resume-platform/internal/service/quiz"
|
||||
"resume-platform/internal/service/resume"
|
||||
"resume-platform/internal/service/system"
|
||||
"resume-platform/internal/service/user"
|
||||
aiservice "resume-platform/internal/service/ai"
|
||||
"resume-platform/pkg/config"
|
||||
)
|
||||
|
||||
type ServiceContainer struct {
|
||||
// Repository
|
||||
Repo repository.ResumeRepository
|
||||
|
||||
// Core Services
|
||||
ResumeService resume.ResumeService
|
||||
SystemService system.SystemService
|
||||
DocumentService document.DocumentService
|
||||
UserService user.UserService
|
||||
PortfolioService portfolio.PortfolioService
|
||||
QuizService quiz.QuizService
|
||||
|
||||
// AI Services
|
||||
AIProvider aiprovider.Provider
|
||||
AIService *aiservice.AIService
|
||||
QuestionGenerator *aiservice.QuestionGeneratorService
|
||||
ResumeGenerator *resume.ResumeGeneratorService
|
||||
ResumeAgent *agent.ResumeAgent
|
||||
|
||||
// Knowledge Services
|
||||
EmbeddingService *knowledge.EmbeddingService
|
||||
VectorService *knowledge.VectorService
|
||||
KnowledgeBaseService *knowledge.KnowledgeBaseService
|
||||
RAGService *knowledge.RAGService
|
||||
|
||||
// Chat Services
|
||||
ChatService chat.ChatService
|
||||
IMService *chat.IMService
|
||||
|
||||
// Handlers
|
||||
ResumeHandler *handler.ResumeHandler
|
||||
AdminHandler *handler.AdminHandler
|
||||
AIHandler *handler.AIHandler
|
||||
DocumentHandler *handler.DocumentHandler
|
||||
ChatHandler *handler.ChatHandler
|
||||
IMHandler *handler.IMHandler
|
||||
KnowledgeHandler *handler.KnowledgeHandler
|
||||
}
|
||||
|
||||
func NewServiceContainer(cfg *config.Config) (*ServiceContainer, error) {
|
||||
container := &ServiceContainer{}
|
||||
|
||||
if err := container.initRepository(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initAIServices(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initCoreServices(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initKnowledgeServices(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initChatServices(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initHandlers(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initRepository(cfg *config.Config) error {
|
||||
dbPath := cfg.Database.Path
|
||||
if dbPath == "" {
|
||||
dbPath = "./data/resume.db"
|
||||
}
|
||||
|
||||
repo, err := repository.NewSQLiteRepository(dbPath, cfg.Database.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Repo = repo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initCoreServices(cfg *config.Config) error {
|
||||
c.ResumeService = resume.NewResumeService(c.Repo)
|
||||
c.SystemService = system.NewSystemService(c.Repo)
|
||||
|
||||
if _, err := c.ResumeService.InitDefaultResume(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.SystemService.InitDefaultMenus(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.DocumentService = document.NewDocumentService(c.Repo, c.ResumeGenerator)
|
||||
c.UserService = user.NewUserService(c.Repo)
|
||||
c.PortfolioService = portfolio.NewPortfolioService(c.Repo)
|
||||
c.QuizService = quiz.NewQuizService(c.Repo)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initAIServices(cfg *config.Config) error {
|
||||
if !cfg.AI.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
provider, err := aiprovider.NewProvider(&cfg.AI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.AIProvider = provider
|
||||
c.AIService = aiservice.NewAIService(provider)
|
||||
c.QuestionGenerator = aiservice.NewQuestionGeneratorService(provider)
|
||||
c.ResumeGenerator = resume.NewResumeGeneratorService(provider)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initKnowledgeServices(cfg *config.Config) error {
|
||||
if c.AIProvider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.EmbeddingService = knowledge.NewEmbeddingService(c.AIProvider, c.Repo)
|
||||
c.VectorService = knowledge.NewVectorService(c.EmbeddingService, c.Repo)
|
||||
c.KnowledgeBaseService = knowledge.NewKnowledgeBaseService(c.EmbeddingService, c.VectorService, c.Repo)
|
||||
c.RAGService = knowledge.NewRAGService(c.AIProvider, c.KnowledgeBaseService)
|
||||
|
||||
c.DocumentService = document.NewDocumentServiceWithKnowledge(c.Repo, c.ResumeGenerator, c.KnowledgeBaseService)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initChatServices(cfg *config.Config) error {
|
||||
c.ChatService = chat.NewChatService(c.Repo, c.AIService)
|
||||
c.IMService = chat.NewIMService(c.AIProvider, c.ResumeAgent)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initHandlers(cfg *config.Config) error {
|
||||
c.ResumeHandler = handler.NewResumeHandler(
|
||||
c.ResumeService,
|
||||
c.DocumentService,
|
||||
c.UserService,
|
||||
c.PortfolioService,
|
||||
c.QuizService,
|
||||
c.SystemService,
|
||||
)
|
||||
|
||||
rateLimiter := security.NewRateLimiter(cfg.Auth.MaxLoginAttempts, cfg.Auth.LockoutDuration)
|
||||
c.AdminHandler = handler.NewAdminHandler(cfg, rateLimiter)
|
||||
c.AIHandler = handler.NewAIHandler(c.AIService, c.QuestionGenerator, nil, c.ResumeService, c.QuizService)
|
||||
c.DocumentHandler = handler.NewDocumentHandler(c.DocumentService)
|
||||
c.ChatHandler = handler.NewChatHandler(c.ChatService)
|
||||
c.IMHandler = handler.NewIMHandler(c.IMService)
|
||||
|
||||
if c.KnowledgeBaseService != nil {
|
||||
c.KnowledgeHandler = handler.NewKnowledgeHandler(c.KnowledgeBaseService, c.RAGService)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) InitResumeAgent(ctx context.Context, cfg *config.Config) error {
|
||||
if !cfg.AI.Enabled || c.AIProvider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
agent, err := agent.NewResumeAgent(ctx, &cfg.AI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.ResumeAgent = agent
|
||||
c.AIHandler.SetResumeAgent(agent)
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"resume-platform/internal/agent"
|
||||
"resume-platform/internal/model"
|
||||
aiservice "resume-platform/internal/service/ai"
|
||||
"resume-platform/internal/service/quiz"
|
||||
"resume-platform/internal/service/resume"
|
||||
"resume-platform/pkg/logger"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AIHandler AI 能力处理器,封装简历润色、分析、题目生成、评分等 AI 相关接口
|
||||
type AIHandler struct {
|
||||
aiService *aiservice.AIService
|
||||
questionGenerator *aiservice.QuestionGeneratorService
|
||||
resumeAgent *agent.ResumeAgent
|
||||
resumeService resume.ResumeService
|
||||
quizService quiz.QuizService
|
||||
}
|
||||
|
||||
// NewAIHandler 创建 AI 处理器实例
|
||||
// @param aiService AI 服务层
|
||||
// @param questionGenerator 题目生成服务层
|
||||
// @param resumeAgent AI Agent 实例
|
||||
// @param resumeService 简历服务层
|
||||
// @param quizService 答题服务层
|
||||
// @return *AIHandler 处理器实例
|
||||
// @author sunct
|
||||
func NewAIHandler(aiService *aiservice.AIService, questionGenerator *aiservice.QuestionGeneratorService, resumeAgent *agent.ResumeAgent, resumeService resume.ResumeService, quizService quiz.QuizService) *AIHandler {
|
||||
return &AIHandler{
|
||||
aiService: aiService,
|
||||
questionGenerator: questionGenerator,
|
||||
resumeAgent: resumeAgent,
|
||||
resumeService: resumeService,
|
||||
quizService: quizService,
|
||||
}
|
||||
}
|
||||
|
||||
// SetResumeAgent 设置 ResumeAgent(延迟初始化使用)
|
||||
// @param resumeAgent AI Agent 实例
|
||||
func (h *AIHandler) SetResumeAgent(resumeAgent *agent.ResumeAgent) {
|
||||
h.resumeAgent = resumeAgent
|
||||
}
|
||||
|
||||
// buildResumeText 将简历对象拼接为纯文本,用于 AI 关键词提取
|
||||
// @param resume 简历数据
|
||||
// @return string 拼接后的纯文本
|
||||
// @author sunct
|
||||
func buildResumeText(resume *model.Resume) string {
|
||||
if resume == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(resume.BasicInfo.Name + " ")
|
||||
sb.WriteString(resume.BasicInfo.Title + " ")
|
||||
sb.WriteString(resume.BasicInfo.Summary + " ")
|
||||
sb.WriteString(resume.BasicInfo.Industry + " ")
|
||||
sb.WriteString(resume.BasicInfo.JobTarget + " ")
|
||||
for _, e := range resume.Experience {
|
||||
sb.WriteString(e.Company + " ")
|
||||
sb.WriteString(e.Position + " ")
|
||||
sb.WriteString(e.Description + " ")
|
||||
for _, h := range e.Highlights {
|
||||
sb.WriteString(h + " ")
|
||||
}
|
||||
}
|
||||
for _, s := range resume.Skills {
|
||||
sb.WriteString(s.Name + " ")
|
||||
sb.WriteString(s.Category + " ")
|
||||
}
|
||||
for _, p := range resume.Projects {
|
||||
sb.WriteString(p.Name + " ")
|
||||
sb.WriteString(p.Description + " ")
|
||||
for _, t := range p.TechStack {
|
||||
sb.WriteString(t + " ")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// PolishText 单字段润色接口,根据字段类型调用不同的 AI 润色 Prompt
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) PolishText(c *gin.Context) {
|
||||
var req struct {
|
||||
Original string `json:"original"`
|
||||
FieldType string `json:"field_type"`
|
||||
Company string `json:"company,omitempty"`
|
||||
Position string `json:"position,omitempty"`
|
||||
ProjectName string `json:"project_name,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Original == "" {
|
||||
BadRequest(c, "original text is required")
|
||||
return
|
||||
}
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
switch req.FieldType {
|
||||
case "summary":
|
||||
result, err = h.aiService.PolishSummary(c.Request.Context(), req.Original)
|
||||
case "experience":
|
||||
result, err = h.aiService.PolishExperience(c.Request.Context(), req.Original, req.Company, req.Position)
|
||||
case "project":
|
||||
result, err = h.aiService.PolishProject(c.Request.Context(), req.Original, req.ProjectName)
|
||||
default:
|
||||
result, err = h.aiService.PolishDescription(c.Request.Context(), req.Original, req.FieldType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": result,
|
||||
"provider": h.aiService.GetProviderName(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetProviderInfo 获取当前使用的 AI 供应商名称
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) GetProviderInfo(c *gin.Context) {
|
||||
provider := h.aiService.GetProviderName()
|
||||
if h.resumeAgent != nil {
|
||||
provider = h.resumeAgent.GetProvider()
|
||||
}
|
||||
OK(c, gin.H{"provider": provider})
|
||||
}
|
||||
|
||||
// AgentPolish Agent 模式润色接口,优先使用 Agent 工具链,降级为普通 AI 润色
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) AgentPolish(c *gin.Context) {
|
||||
var req struct {
|
||||
Original string `json:"original"`
|
||||
FieldType string `json:"field_type"`
|
||||
Company string `json:"company,omitempty"`
|
||||
Position string `json:"position,omitempty"`
|
||||
ProjectName string `json:"project_name,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Original == "" {
|
||||
BadRequest(c, "original text is required")
|
||||
return
|
||||
}
|
||||
|
||||
context := ""
|
||||
if req.Company != "" {
|
||||
context += "公司:" + req.Company + " "
|
||||
}
|
||||
if req.Position != "" {
|
||||
context += "职位:" + req.Position + " "
|
||||
}
|
||||
if req.ProjectName != "" {
|
||||
context += "项目名称:" + req.ProjectName
|
||||
}
|
||||
|
||||
if h.resumeAgent != nil {
|
||||
query := fmt.Sprintf("请帮我优化简历的%s字段。原文:%s", getFieldTypeName(req.FieldType), req.Original)
|
||||
if context != "" {
|
||||
query += "。上下文:" + context
|
||||
}
|
||||
|
||||
result, err := h.resumeAgent.Run(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": result,
|
||||
"provider": h.resumeAgent.GetProvider(),
|
||||
"agent_mode": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
switch req.FieldType {
|
||||
case "summary":
|
||||
result, err = h.aiService.PolishSummary(c.Request.Context(), req.Original)
|
||||
case "experience":
|
||||
result, err = h.aiService.PolishExperience(c.Request.Context(), req.Original, req.Company, req.Position)
|
||||
case "project":
|
||||
result, err = h.aiService.PolishProject(c.Request.Context(), req.Original, req.ProjectName)
|
||||
default:
|
||||
result, err = h.aiService.PolishDescription(c.Request.Context(), req.Original, req.FieldType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": result,
|
||||
"provider": h.aiService.GetProviderName(),
|
||||
"agent_mode": false,
|
||||
})
|
||||
}
|
||||
|
||||
// AgentAnalyze 简历整体分析接口,调用 Agent 对简历进行专业评估
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) AgentAnalyze(c *gin.Context) {
|
||||
var req struct {
|
||||
ResumeText string `json:"resume_text"`
|
||||
TargetPosition string `json:"target_position,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.ResumeText == "" {
|
||||
BadRequest(c, "resume_text is required")
|
||||
return
|
||||
}
|
||||
|
||||
if h.resumeAgent != nil {
|
||||
query := "请分析以下简历内容并提供专业评估:" + req.ResumeText
|
||||
if req.TargetPosition != "" {
|
||||
query += "。目标职位:" + req.TargetPosition
|
||||
}
|
||||
|
||||
result, err := h.resumeAgent.Run(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": result,
|
||||
"provider": h.resumeAgent.GetProvider(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": "Eino Agent not available",
|
||||
"provider": h.aiService.GetProviderName(),
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateQuestions AI 生成面试题接口,支持按简历内容或关键词出题
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) GenerateQuestions(c *gin.Context) {
|
||||
var req struct {
|
||||
ResumeID string `json:"resume_id,omitempty"`
|
||||
ResumeText string `json:"resume_text,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
Types string `json:"types,omitempty"`
|
||||
Seed string `json:"seed,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.ResumeText == "" && req.ResumeID == "" && req.Keywords == "" {
|
||||
BadRequest(c, "resume_text, resume_id or keywords is required")
|
||||
return
|
||||
}
|
||||
|
||||
resumeText := req.ResumeText
|
||||
keywords := req.Keywords
|
||||
|
||||
// 当提供 resume_id 时,真正获取简历内容并提取文本
|
||||
if req.ResumeID != "" {
|
||||
resume, err := h.resumeService.GetResume(req.ResumeID)
|
||||
if err != nil || resume == nil {
|
||||
Error(c, "简历不存在")
|
||||
return
|
||||
}
|
||||
text := buildResumeText(resume)
|
||||
if text != "" {
|
||||
// 简历文本作为基础,与已有 resumeText 合并
|
||||
if resumeText != "" {
|
||||
resumeText = resumeText + " " + text
|
||||
} else {
|
||||
resumeText = text
|
||||
}
|
||||
}
|
||||
// 从简历技能中补充关键词
|
||||
if resume != nil && len(resume.Skills) > 0 && keywords == "" {
|
||||
var skillNames []string
|
||||
for _, s := range resume.Skills {
|
||||
if s.Name != "" {
|
||||
skillNames = append(skillNames, s.Name)
|
||||
}
|
||||
}
|
||||
if len(skillNames) > 0 {
|
||||
keywords = strings.Join(skillNames, ",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当只有 keywords 时,构造基础文本
|
||||
if resumeText == "" && keywords != "" {
|
||||
resumeText = "基于关键词:" + keywords
|
||||
}
|
||||
|
||||
if resumeText == "" {
|
||||
BadRequest(c, "无法获取简历内容或关键词")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
logger.CtxInfof(ctx, "[AIHandler] GenerateQuestions resume_id=%s, keywords=%s, resume_text_len=%d", req.ResumeID, keywords, len(resumeText))
|
||||
|
||||
var questions []map[string]interface{}
|
||||
var err error
|
||||
|
||||
if h.questionGenerator != nil {
|
||||
logger.CtxInfof(ctx, "[AIHandler] Using AI question generator")
|
||||
questions, err = h.questionGenerator.GenerateQuestions(ctx, resumeText, keywords, req.Types)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] GenerateQuestions failed: %v", err)
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logger.CtxInfof(ctx, "[AIHandler] Using fallback question generator")
|
||||
questions, err = h.generateFallbackQuestions(ctx, resumeText, keywords, req.Types, req.Seed)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] generateFallbackQuestions failed: %v", err)
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(questions) == 0 {
|
||||
Error(c, "未找到与关键词匹配的题目,请尝试其他关键词")
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"questions": questions})
|
||||
}
|
||||
|
||||
// generateFallbackQuestions 备用题目生成方法,当 AI 服务不可用时使用本地题库
|
||||
// @param resumeText 简历文本
|
||||
// @param keywords 关键词
|
||||
// @param types 题目类型
|
||||
// @param seed 随机种子
|
||||
// @return []map[string]interface{} 题目列表
|
||||
// @return error 错误信息
|
||||
// @author sunct
|
||||
func (h *AIHandler) generateFallbackQuestions(ctx context.Context, resumeText, keywords, types, seed string) ([]map[string]interface{}, error) {
|
||||
logger.CtxInfof(ctx, "[AIHandler] generateFallbackQuestions keywords=%s, types=%s", keywords, types)
|
||||
|
||||
tool := agent.NewGenerateQuestionsTool()
|
||||
|
||||
args := map[string]interface{}{
|
||||
"resume_text": resumeText,
|
||||
"keywords": keywords,
|
||||
"types": types,
|
||||
"seed": seed,
|
||||
"random": true,
|
||||
}
|
||||
|
||||
argsJSON, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] Failed to marshal arguments: %v", err)
|
||||
return nil, fmt.Errorf("failed to marshal arguments: %w", err)
|
||||
}
|
||||
|
||||
resultJSON, err := tool.InvokableRun(ctx, string(argsJSON))
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] tool.InvokableRun failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var questions []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(resultJSON), &questions); err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] Failed to parse questions: %v", err)
|
||||
return nil, fmt.Errorf("failed to parse questions: %w", err)
|
||||
}
|
||||
|
||||
logger.CtxInfof(ctx, "[AIHandler] generateFallbackQuestions success, count=%d", len(questions))
|
||||
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// EvaluateAnswers 本地答题评分接口,选择/填空按答案比对,简答按非空判定
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) EvaluateAnswers(c *gin.Context) {
|
||||
var req struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
Answers map[string]string `json:"answers"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
BadRequest(c, "questions is required")
|
||||
return
|
||||
}
|
||||
|
||||
correctCount := 0
|
||||
|
||||
for i, question := range req.Questions {
|
||||
answerKey := fmt.Sprintf("%d", i)
|
||||
userAnswer := req.Answers[answerKey]
|
||||
correctAnswer := question["answer"]
|
||||
|
||||
if correctAnswer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
correctStr := fmt.Sprintf("%v", correctAnswer)
|
||||
|
||||
if question["type"] == "mcq" || question["type"] == "fill" {
|
||||
if strings.EqualFold(strings.TrimSpace(userAnswer), strings.TrimSpace(correctStr)) {
|
||||
correctCount++
|
||||
}
|
||||
} else if question["type"] == "sa" {
|
||||
if len(strings.TrimSpace(userAnswer)) > 0 {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalQuestions := len(req.Questions)
|
||||
score := float64(correctCount) / float64(totalQuestions) * 100
|
||||
|
||||
OK(c, gin.H{
|
||||
"score": score,
|
||||
"correct_count": correctCount,
|
||||
"total_count": totalQuestions,
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitAnswers 提交答题记录接口,计算得分并保存答题记录到数据库
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) SubmitAnswers(c *gin.Context) {
|
||||
var req struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
UserAnswers map[string]string `json:"user_answers"`
|
||||
Score float64 `json:"score"`
|
||||
CorrectCount int `json:"correct_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
UnansweredCount int `json:"unanswered_count"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
ResumeID string `json:"resume_id,omitempty"`
|
||||
ResumeRoute string `json:"resume_route,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
BadRequest(c, "questions is required")
|
||||
return
|
||||
}
|
||||
|
||||
questions := make([]model.QuizQuestion, 0, len(req.Questions))
|
||||
for _, q := range req.Questions {
|
||||
question := model.QuizQuestion{
|
||||
Type: getStringValue(q, "type"),
|
||||
Text: getStringValue(q, "text"),
|
||||
Answer: getStringValue(q, "answer"),
|
||||
Analysis: getStringValue(q, "analysis"),
|
||||
Score: getIntValue(q, "score"),
|
||||
Difficulty: getStringValue(q, "difficulty"),
|
||||
Category: getStringValue(q, "category"),
|
||||
}
|
||||
|
||||
if options, ok := q["options"].([]interface{}); ok {
|
||||
question.Options = make([]string, 0, len(options))
|
||||
for _, opt := range options {
|
||||
question.Options = append(question.Options, fmt.Sprintf("%v", opt))
|
||||
}
|
||||
}
|
||||
|
||||
if keywords, ok := q["keywords"].([]interface{}); ok {
|
||||
question.Keywords = make([]string, 0, len(keywords))
|
||||
for _, kw := range keywords {
|
||||
question.Keywords = append(question.Keywords, fmt.Sprintf("%v", kw))
|
||||
}
|
||||
}
|
||||
|
||||
questions = append(questions, question)
|
||||
}
|
||||
|
||||
record := &model.QuizRecord{
|
||||
UserID: req.UserID,
|
||||
ResumeID: req.ResumeID,
|
||||
ResumeRoute: req.ResumeRoute,
|
||||
Title: req.Title,
|
||||
Questions: questions,
|
||||
UserAnswers: req.UserAnswers,
|
||||
Score: req.Score,
|
||||
CorrectCount: req.CorrectCount,
|
||||
TotalCount: req.TotalCount,
|
||||
}
|
||||
|
||||
if h.quizService != nil {
|
||||
err := h.quizService.CreateQuizRecord(record)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(c.Request.Context(), "[AIHandler] SubmitAnswers failed to save record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("答题完成!得分:%.1f分", req.Score)
|
||||
if req.UnansweredCount > 0 {
|
||||
message += fmt.Sprintf("(%d/%d正确,%d题未作答)", req.CorrectCount, req.TotalCount-req.UnansweredCount, req.UnansweredCount)
|
||||
} else {
|
||||
message += fmt.Sprintf("(%d/%d正确)", req.CorrectCount, req.TotalCount)
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"message": message,
|
||||
"score": req.Score,
|
||||
"correct_count": req.CorrectCount,
|
||||
"total_count": req.TotalCount,
|
||||
"unanswered_count": req.UnansweredCount,
|
||||
"record_id": record.ID,
|
||||
}, message)
|
||||
}
|
||||
|
||||
func getStringValue(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIntValue(m map[string]interface{}, key string) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case float64:
|
||||
return int(val)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getFieldTypeName 将字段类型英文标识转换为中文名称
|
||||
// @param fieldType 字段类型
|
||||
// @return string 中文名称
|
||||
// @author sunct
|
||||
func getFieldTypeName(fieldType string) string {
|
||||
switch fieldType {
|
||||
case "summary":
|
||||
return "个人简介"
|
||||
case "experience":
|
||||
return "工作经历"
|
||||
case "project":
|
||||
return "项目描述"
|
||||
case "education":
|
||||
return "教育背景"
|
||||
case "skill":
|
||||
return "技能描述"
|
||||
default:
|
||||
return "内容"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/internal/service/chat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChatHandler 聊天对话处理器,基于文档向量库进行问答
|
||||
type ChatHandler struct {
|
||||
chatService chat.ChatService
|
||||
}
|
||||
|
||||
// NewChatHandler 创建聊天处理器实例
|
||||
// @param chatService 聊天服务层
|
||||
// @return *ChatHandler 处理器实例
|
||||
// @author sunct
|
||||
func NewChatHandler(chatService chat.ChatService) *ChatHandler {
|
||||
return &ChatHandler{chatService: chatService}
|
||||
}
|
||||
|
||||
// Chat 流式对话接口,以 SSE 方式返回 AI 回答
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *ChatHandler) Chat(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var request struct {
|
||||
Question string `json:"question"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
answer, err := h.chatService.Chat(c.Request.Context(), userID, request.Question)
|
||||
if err != nil {
|
||||
c.SSEvent("error", gin.H{"message": err.Error()})
|
||||
return false
|
||||
}
|
||||
|
||||
c.SSEvent("message", answer)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// GetChatHistory 获取用户聊天历史记录接口
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *ChatHandler) GetChatHistory(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
history, err := h.chatService.GetChatHistory(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"resume-platform/internal/service/document"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DocumentHandler 文档处理器,负责作品集文档的上传、查询和删除
|
||||
type DocumentHandler struct {
|
||||
documentService document.DocumentService
|
||||
}
|
||||
|
||||
// NewDocumentHandler 创建文档处理器实例
|
||||
// @param documentService 文档服务层
|
||||
// @return *DocumentHandler 处理器实例
|
||||
// @author sunct
|
||||
func NewDocumentHandler(documentService document.DocumentService) *DocumentHandler {
|
||||
return &DocumentHandler{documentService: documentService}
|
||||
}
|
||||
|
||||
// UploadDocument 上传文档接口,读取文件内容后交给 Service 层做向量化处理
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *DocumentHandler) UploadDocument(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
fileType := filepath.Ext(file.Filename)
|
||||
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
document, err := h.documentService.UploadDocument(c.Request.Context(), userID, file.Filename, fileType, file.Size, string(content))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": document.ID, "name": document.Name, "status": document.Status})
|
||||
}
|
||||
|
||||
// GetDocuments 获取用户文档列表接口
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *DocumentHandler) GetDocuments(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
documents, err := h.documentService.GetDocuments(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, documents)
|
||||
}
|
||||
|
||||
// GetDocument 获取单篇文档详情接口
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *DocumentHandler) GetDocument(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
documentID := c.Param("id")
|
||||
|
||||
if userID == "" || documentID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id and document_id are required"})
|
||||
return
|
||||
}
|
||||
|
||||
document, err := h.documentService.GetDocument(c.Request.Context(), userID, documentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, document)
|
||||
}
|
||||
|
||||
// DeleteDocument 删除文档接口
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
documentID := c.Param("id")
|
||||
|
||||
if userID == "" || documentID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id and document_id are required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.documentService.DeleteDocument(c.Request.Context(), userID, documentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "document deleted successfully"})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/internal/service/chat"
|
||||
"resume-platform/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type IMHandler struct {
|
||||
imService *chat.IMService
|
||||
}
|
||||
|
||||
func NewIMHandler(imService *chat.IMService) *IMHandler {
|
||||
return &IMHandler{imService: imService}
|
||||
}
|
||||
|
||||
func (h *IMHandler) WebSocket(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to upgrade connection: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ctx := c.Request.Context()
|
||||
logger.CtxInfof(ctx, "WebSocket connection opened")
|
||||
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
logger.CtxInfof(ctx, "WebSocket connection closed normally")
|
||||
} else {
|
||||
logger.CtxErrorf(ctx, "WebSocket read error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
var request struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &request); err != nil {
|
||||
logger.CtxErrorf(ctx, "Failed to parse message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if request.Message == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
go h.handleMessage(ctx, conn, request.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IMHandler) handleMessage(ctx context.Context, conn *websocket.Conn, message string) {
|
||||
logger.CtxInfof(ctx, "Received message: %s", message)
|
||||
|
||||
agent := h.imService.GetResumeAgent()
|
||||
var result string
|
||||
var err error
|
||||
if agent != nil {
|
||||
logger.CtxInfof(ctx, "Using Eino Resume Agent for request")
|
||||
result, err = h.imService.GenerateWithAgent(ctx, message)
|
||||
} else {
|
||||
logger.CtxInfof(ctx, "Using AI Provider for request")
|
||||
result, err = h.imService.GetAIProvider().Generate(ctx, message)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
|
||||
response := map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": err.Error(),
|
||||
}
|
||||
if err := conn.WriteJSON(response); err != nil {
|
||||
logger.CtxErrorf(ctx, "Failed to send error response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"type": "message",
|
||||
"content": result,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(response); err != nil {
|
||||
logger.CtxErrorf(ctx, "Failed to send response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IMHandler) Chat(c *gin.Context) {
|
||||
var request struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Message == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
logger.CtxInfof(ctx, "Chat request: %s", request.Message)
|
||||
|
||||
result, err := h.imService.GetAIProvider().Generate(ctx, request.Message)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"response": result})
|
||||
}
|
||||
|
||||
func (h *IMHandler) ChatStream(c *gin.Context) {
|
||||
var request struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Message == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
logger.CtxInfof(ctx, "Chat stream request: %s", request.Message)
|
||||
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
result, err := h.imService.GetAIProvider().Generate(ctx, request.Message)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
|
||||
c.SSEvent("error", gin.H{"message": err.Error()})
|
||||
return false
|
||||
}
|
||||
|
||||
for _, char := range result {
|
||||
c.SSEvent("message", string(char))
|
||||
}
|
||||
c.SSEvent("done", gin.H{})
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/service/knowledge"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type KnowledgeHandler struct {
|
||||
knowledgeBaseService *knowledge.KnowledgeBaseService
|
||||
ragService *knowledge.RAGService
|
||||
}
|
||||
|
||||
func NewKnowledgeHandler(knowledgeBaseService *knowledge.KnowledgeBaseService, ragService *knowledge.RAGService) *KnowledgeHandler {
|
||||
return &KnowledgeHandler{knowledgeBaseService: knowledgeBaseService, ragService: ragService}
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) Search(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
topK := 5
|
||||
if k := c.Query("k"); k != "" {
|
||||
fmt.Sscanf(k, "%d", &topK)
|
||||
}
|
||||
userID := c.GetHeader("X-User-ID")
|
||||
|
||||
if query == "" {
|
||||
BadRequest(c, "Query parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.knowledgeBaseService.Retrieve(c.Request.Context(), query, topK, userID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, results)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) GetStats(c *gin.Context) {
|
||||
userID := c.GetHeader("X-User-ID")
|
||||
|
||||
stats, err := h.knowledgeBaseService.GetStats(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, stats)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) AddDocument(c *gin.Context) {
|
||||
var req struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
Content string `json:"content"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.DocumentID == "" || req.Content == "" {
|
||||
BadRequest(c, "document_id and content are required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.knowledgeBaseService.AddDocument(c.Request.Context(), req.DocumentID, req.Content, req.UserID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"message": "Document added to knowledge base successfully"})
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) AddResume(c *gin.Context) {
|
||||
var req struct {
|
||||
ResumeID string `json:"resume_id"`
|
||||
Resume map[string]interface{} `json:"resume"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.ResumeID == "" {
|
||||
BadRequest(c, "resume_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
resumeJSON, _ := json.Marshal(req.Resume)
|
||||
var resume model.Resume
|
||||
if err := json.Unmarshal(resumeJSON, &resume); err != nil {
|
||||
BadRequest(c, "Invalid resume format")
|
||||
return
|
||||
}
|
||||
|
||||
resume.ID = req.ResumeID
|
||||
resume.UserID = req.UserID
|
||||
|
||||
err := h.knowledgeBaseService.AddResume(c.Request.Context(), req.ResumeID, &resume)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"message": "Resume added to knowledge base successfully"})
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) AddText(c *gin.Context) {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Text == "" || req.SourceType == "" {
|
||||
BadRequest(c, "text and source_type are required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.knowledgeBaseService.AddText(c.Request.Context(), req.Text, req.SourceType, req.SourceID, req.SourceName, req.UserID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"message": "Text added to knowledge base successfully"})
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) Chat(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Query == "" {
|
||||
BadRequest(c, "query is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.ragService.Generate(c.Request.Context(), req.Query, req.UserID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"answer": result})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package handler 处理所有 HTTP 请求,负责参数校验、调用 Service 层和返回响应
|
||||
// 按业务模块分为简历管理、后台管理、AI 能力、文档处理、聊天对话等处理器
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIResponse 统一 API 响应结构体,所有接口返回格式遵循此结构
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"` // 请求是否成功
|
||||
Message string `json:"message,omitempty"`// 提示消息
|
||||
Data interface{} `json:"data,omitempty"` // 响应数据
|
||||
Error string `json:"error,omitempty"` // 错误信息
|
||||
}
|
||||
|
||||
// OK 返回 200 成功响应,附带数据和可选消息
|
||||
// @param c Gin 上下文
|
||||
// @param data 响应数据
|
||||
// @param message 可选提示消息
|
||||
// @author sunct
|
||||
func OK(c *gin.Context, data interface{}, message ...string) {
|
||||
msg := ""
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
c.JSON(http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// Created 返回 201 创建成功响应
|
||||
// @param c Gin 上下文
|
||||
// @param data 创建的资源数据
|
||||
// @param message 可选提示消息
|
||||
// @author sunct
|
||||
func Created(c *gin.Context, data interface{}, message ...string) {
|
||||
msg := ""
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
c.JSON(http.StatusCreated, APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// Success 返回 200 成功响应,仅包含提示消息
|
||||
// @param c Gin 上下文
|
||||
// @param message 成功提示消息
|
||||
// @author sunct
|
||||
func Success(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// BadRequest 返回 400 参数错误响应
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func BadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// NotFound 返回 404 资源不存在响应
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func NotFound(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusNotFound, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Conflict 返回 409 资源冲突响应(如重复创建)
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func Conflict(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusConflict, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Unauthorized 返回 401 未授权响应
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func Unauthorized(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusUnauthorized, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// TooManyRequests 返回 429 请求过于频繁响应(限流)
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func TooManyRequests(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusTooManyRequests, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Error 返回 500 服务器内部错误响应
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func Error(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// SuccessResponse 返回成功响应(支持标准库 http.ResponseWriter)
|
||||
// @param w http.ResponseWriter
|
||||
// @param data 响应数据
|
||||
func SuccessResponse(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrorResponse 返回错误响应(支持标准库 http.ResponseWriter)
|
||||
// @param w http.ResponseWriter
|
||||
// @param statusCode HTTP 状态码
|
||||
// @param message 错误消息
|
||||
func ErrorResponse(w http.ResponseWriter, statusCode int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
// Package middleware Gin 中间件层,提供请求日志、认证等横切能力
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"resume-platform/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RequestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
method := c.Request.Method
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
statusCode := c.Writer.Status()
|
||||
clientIP := c.ClientIP()
|
||||
referer := c.Request.Referer()
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
entry := logger.FromContext(c.Request.Context())
|
||||
entry = entry.WithFields(map[string]interface{}{
|
||||
"status": statusCode,
|
||||
"latency": latency,
|
||||
"ip": clientIP,
|
||||
"method": method,
|
||||
"path": path,
|
||||
})
|
||||
|
||||
if referer != "" {
|
||||
entry = entry.WithField("referer", referer)
|
||||
}
|
||||
if len(c.Errors) > 0 {
|
||||
entry = entry.WithField("errors", c.Errors.String())
|
||||
}
|
||||
|
||||
switch {
|
||||
case statusCode >= 500:
|
||||
entry.WithField("user_agent", userAgent).Error("Server error")
|
||||
case statusCode >= 400:
|
||||
entry.Warn("Client error")
|
||||
default:
|
||||
entry.Info("Request handled")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"resume-platform/pkg/constant"
|
||||
"resume-platform/pkg/idgen"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Tracing(appName string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reqId, _ := idgen.GetID()
|
||||
|
||||
ctx := context.WithValue(c.Request.Context(), constant.RequestIdKey, reqId)
|
||||
ctx = context.WithValue(ctx, constant.AppNameKey, appName)
|
||||
ctx = context.WithValue(ctx, constant.RequestRouteKey, c.Request.URL.Path)
|
||||
ctx = context.WithValue(ctx, constant.ClientIP, c.ClientIP())
|
||||
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package model 常量定义,包含系统默认配置和全局常量
|
||||
package model
|
||||
|
||||
const (
|
||||
// DefaultPageSize 后台列表默认分页大小
|
||||
DefaultPageSize = 10
|
||||
// MaxPageSize 后台列表最大分页大小
|
||||
MaxPageSize = 100
|
||||
|
||||
// DefaultTemplate 默认简历模板名称
|
||||
DefaultTemplate = "modern"
|
||||
|
||||
// AdminTokenFile 管理员令牌持久化文件路径
|
||||
AdminTokenFile = "./data/admin_tokens.json"
|
||||
// SettingsFile 系统设置持久化文件路径(预留)
|
||||
SettingsFile = "./data/settings.json"
|
||||
|
||||
// DefaultWebsiteTitle 默认网站标题
|
||||
DefaultWebsiteTitle = "景笺 - 开源简历平台"
|
||||
// DefaultWebsiteDesc 默认网站简介
|
||||
DefaultWebsiteDesc = "现代化的开源简历平台,支持多人简历管理、自定义模板、实时预览"
|
||||
// DefaultWebsiteKeywords 默认网站关键词
|
||||
DefaultWebsiteKeywords = "简历平台,开源,个人主页,作品集,简历管理"
|
||||
// DefaultWebsiteDomain 默认网站域名
|
||||
DefaultWebsiteDomain = "http://localhost:8090"
|
||||
|
||||
// DefaultAdminUsername 默认管理员用户名
|
||||
DefaultAdminUsername = "admin"
|
||||
// DefaultAdminPassword 默认管理员密码
|
||||
DefaultAdminPassword = "admin123"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
package model
|
||||
|
||||
// Document 上传文档模型,记录文档元数据和原始内容
|
||||
type Document struct {
|
||||
ID string `json:"id"` // 文档唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
Name string `json:"name"` // 文档名称
|
||||
Type string `json:"type"` // 文件类型(扩展名)
|
||||
Size int64 `json:"size"` // 文件大小(字节)
|
||||
MIMEType string `json:"mime_type"` // MIME类型
|
||||
Content string `json:"content"` // 文档原始内容
|
||||
Status string `json:"status"` // 处理状态:uploading/processing/processed/failed
|
||||
ErrorMsg string `json:"error_msg,omitempty"` // 处理失败的错误信息
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// DocumentChunk 文档切片模型,用于向量检索的文本块
|
||||
type DocumentChunk struct {
|
||||
ID string `json:"id"` // 切片唯一标识
|
||||
DocumentID string `json:"document_id"` // 关联的文档ID
|
||||
ChunkIndex int `json:"chunk_index"` // 切片序号
|
||||
Content string `json:"content"` // 切片文本内容
|
||||
Embedding string `json:"embedding"` // 向量嵌入(JSON格式)
|
||||
Metadata string `json:"metadata"` // 元数据(JSON格式)
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// ChatHistory 聊天历史记录模型
|
||||
type ChatHistory struct {
|
||||
ID string `json:"id"` // 记录唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
Question string `json:"question"` // 用户问题
|
||||
Answer string `json:"answer"` // AI回答
|
||||
Context string `json:"context"` // 参考的文档上下文
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// Menu 后台菜单模型
|
||||
type Menu struct {
|
||||
ID string `json:"id"` // 菜单唯一标识
|
||||
Name string `json:"name"` // 菜单名称
|
||||
Icon string `json:"icon"` // 图标类名
|
||||
Path string `json:"path"` // 路由路径
|
||||
ParentID string `json:"parent_id"` // 父菜单ID
|
||||
SortOrder int `json:"sort_order"` // 排序权重
|
||||
IsFixed bool `json:"is_fixed"` // 是否固定菜单(不可删除)
|
||||
IsEnabled bool `json:"is_enabled"` // 是否启用
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// SystemConfig 系统配置模型,存储网站全局设置
|
||||
type SystemConfig struct {
|
||||
ID string `json:"id"` // 配置唯一标识
|
||||
WebsiteDomain string `json:"website_domain"` // 网站域名
|
||||
WebsiteLogo string `json:"website_logo"` // 网站Logo(Base64)
|
||||
LogoPath string `json:"logo_path"` // Logo文件路径
|
||||
WebsiteTitle string `json:"website_title"` // 网站标题
|
||||
WebsiteDesc string `json:"website_desc"` // 网站简介(短)
|
||||
WebsiteDescription string `json:"website_description"` // 网站描述(长)
|
||||
WebsiteKeywords string `json:"website_keywords"` // 网站关键词
|
||||
AdminEmail string `json:"admin_email"` // 管理员邮箱
|
||||
AdminName string `json:"admin_name"` // 管理员名称
|
||||
SEOKeywords string `json:"seo_keywords"` // SEO关键词
|
||||
SEODescription string `json:"seo_description"` // SEO描述
|
||||
DatabasePath string `json:"database_path"` // 数据库文件路径
|
||||
AdminPageSize int `json:"admin_page_size"` // 后台列表分页大小
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// LoginHistory 登录历史记录模型
|
||||
type LoginHistory struct {
|
||||
ID string `json:"id"` // 记录唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
UserName string `json:"user_name"` // 用户名
|
||||
IP string `json:"ip"` // 登录IP
|
||||
Location string `json:"location"` // 登录地点
|
||||
UserAgent string `json:"user_agent"` // 浏览器UA
|
||||
Success bool `json:"success"` // 是否登录成功
|
||||
Message string `json:"message"` // 结果消息
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// Notification 系统通知模型
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // 通知类型:user/resume/portfolio/quiz/system
|
||||
Title string `json:"title"` // 通知标题
|
||||
Message string `json:"message"` // 通知内容
|
||||
URL string `json:"url"` // 点击跳转链接
|
||||
IsRead bool `json:"is_read"` // 是否已读
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
// Embedding 向量嵌入模型,用于知识库检索
|
||||
type Embedding struct {
|
||||
ID string `json:"id"` // 向量唯一标识
|
||||
ContentHash string `json:"content_hash"` // 内容哈希(去重)
|
||||
Embedding string `json:"embedding"` // 向量嵌入(JSON数组)
|
||||
SourceType string `json:"source_type"` // 来源类型(document/resume/text)
|
||||
SourceID string `json:"source_id"` // 来源ID
|
||||
SourceName string `json:"source_name"` // 来源名称
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// ChunkWithScore 带相似度评分的文档片段,用于向量检索排序
|
||||
type ChunkWithScore struct {
|
||||
Content string `json:"content"` // 片段内容
|
||||
Score float64 `json:"score"` // 相似度评分
|
||||
Metadata string `json:"metadata"` // 元数据(含向量JSON)
|
||||
SourceID string `json:"source_id"` // 来源ID
|
||||
SourceName string `json:"source_name"` // 来源名称
|
||||
SourceType string `json:"source_type"` // 来源类型
|
||||
}
|
||||
|
||||
// KnowledgeBaseStats 知识库统计信息
|
||||
type KnowledgeBaseStats struct {
|
||||
TotalDocuments int `json:"total_documents"` // 文档总数
|
||||
TotalChunks int `json:"total_chunks"` // 切片总数
|
||||
TotalEmbeddings int `json:"total_embeddings"` // 向量总数
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package model 数据模型层,定义所有业务实体结构
|
||||
// 包含用户、简历、作品集、答题记录等核心数据模型
|
||||
package model
|
||||
|
||||
// User 用户模型,用于用户认证和权限管理
|
||||
type User struct {
|
||||
ID string `json:"id"` // 用户唯一标识
|
||||
Username string `json:"username"` // 用户名(唯一)
|
||||
PasswordHash string `json:"password_hash"` // 密码哈希值
|
||||
Email string `json:"email"` // 用户邮箱
|
||||
Name string `json:"name"` // 用户姓名
|
||||
Phone string `json:"phone"` // 用户手机号
|
||||
Role string `json:"role"` // 用户角色(admin/user)
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// Resume 简历模型,存储个人简历的完整信息
|
||||
type Resume struct {
|
||||
ID string `json:"id"` // 简历唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
Route string `json:"route"` // 访问路由(唯一,如 zhangsan)
|
||||
Password string `json:"password"` // 简历密码(用于访问保护)
|
||||
BasicInfo BasicInfo `json:"basic_info"` // 基本信息
|
||||
Experience []Experience `json:"experience"` // 工作经历列表
|
||||
Education []Education `json:"education"` // 教育背景列表
|
||||
Skills []Skill `json:"skills"` // 技能列表
|
||||
Projects []Project `json:"projects"` // 项目经验列表
|
||||
Template string `json:"template"` // 使用的模板名称(默认modern)
|
||||
ShowInHome bool `json:"show_in_home"` // 是否在首页展示
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// BasicInfo 基本信息,包含个人基本资料
|
||||
type BasicInfo struct {
|
||||
Name string `json:"name"` // 姓名
|
||||
Title string `json:"title"` // 职位/头衔
|
||||
Email string `json:"email"` // 邮箱地址
|
||||
Phone string `json:"phone"` // 手机号码
|
||||
Location string `json:"location"` // 所在地
|
||||
Website string `json:"website"` // 个人网站地址
|
||||
Summary string `json:"summary"` // 个人简介
|
||||
Avatar string `json:"avatar"` // 头像URL
|
||||
ExperienceYears string `json:"experience_years"` // 工作年限
|
||||
JobTarget string `json:"job_target"` // 求职目标
|
||||
Industry string `json:"industry"` // 所属行业
|
||||
CoreAdvantages []string `json:"core_advantages"` // 核心优势列表
|
||||
}
|
||||
|
||||
// Experience 工作经历,记录职业发展历程
|
||||
type Experience struct {
|
||||
ID uint `json:"id"` // 经历唯一标识
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
Company string `json:"company"` // 公司名称
|
||||
Position string `json:"position"` // 职位名称
|
||||
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM)
|
||||
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM,"至今"表示当前)
|
||||
Description string `json:"description"` // 工作描述
|
||||
Highlights []string `json:"highlights"` // 工作亮点列表
|
||||
Platforms []string `json:"platforms"` // 线上平台列表
|
||||
}
|
||||
|
||||
// Education 教育背景,记录学习经历
|
||||
type Education struct {
|
||||
ID uint `json:"id"` // 教育背景唯一标识
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
School string `json:"school"` // 学校名称
|
||||
Degree string `json:"degree"` // 学位(本科/硕士/博士等)
|
||||
Major string `json:"major"` // 专业名称
|
||||
StartDate string `json:"start_date"` // 入学日期(格式:YYYY-MM)
|
||||
EndDate string `json:"end_date"` // 毕业日期(格式:YYYY-MM)
|
||||
GPA string `json:"gpa"` // GPA成绩
|
||||
}
|
||||
|
||||
// Skill 技能,记录个人技能和熟练程度
|
||||
type Skill struct {
|
||||
ID uint `json:"id"` // 技能唯一标识
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
Name string `json:"name"` // 技能名称
|
||||
Level string `json:"level"` // 熟练程度(beginner/intermediate/advanced/expert)
|
||||
Category string `json:"category"` // 技能分类(后端/前端/数据库/运维等)
|
||||
}
|
||||
|
||||
// Project 项目经验,记录参与的项目
|
||||
type Project struct {
|
||||
ID uint `json:"id"` // 项目唯一标识
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
Name string `json:"name"` // 项目名称
|
||||
Description string `json:"description"` // 项目描述
|
||||
TechStack []string `json:"tech_stack"` // 技术栈列表
|
||||
URL string `json:"url"` // 项目链接
|
||||
Highlights []string `json:"highlights"` // 项目亮点列表
|
||||
Achievements []string `json:"achievements"` // 项目成果列表
|
||||
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM)
|
||||
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM)
|
||||
ShowInResume bool `json:"show_in_resume"` // 是否在简历中显示
|
||||
}
|
||||
|
||||
// PortfolioItem 作品集项目,用于展示详细的项目文档
|
||||
type PortfolioItem struct {
|
||||
ID uint `json:"id"` // 作品集项目唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
Name string `json:"name"` // 项目名称
|
||||
Description string `json:"description"` // 项目描述
|
||||
TechStack []string `json:"tech_stack"` // 技术栈列表
|
||||
URL string `json:"url"` // 项目链接
|
||||
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM)
|
||||
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM)
|
||||
Doc string `json:"doc"` // 项目文档(Markdown格式)
|
||||
ShowInResume bool `json:"show_in_resume"` // 是否在简历中显示
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
ProjectID uint `json:"project_id"` // 关联的简历项目ID
|
||||
Password string `json:"password"` // 访问密码
|
||||
Hidden bool `json:"hidden"` // 是否隐藏
|
||||
}
|
||||
|
||||
// ResumeUpdate 简历更新请求模型,用于部分更新简历内容
|
||||
type ResumeUpdate struct {
|
||||
BasicInfo *BasicInfo `json:"basic_info"` // 基本信息(可选)
|
||||
Experience *[]Experience `json:"experience"` // 工作经历列表(可选)
|
||||
Education *[]Education `json:"education"` // 教育背景列表(可选)
|
||||
Skills *[]Skill `json:"skills"` // 技能列表(可选)
|
||||
Projects *[]Project `json:"projects"` // 项目经验列表(可选)
|
||||
Template *string `json:"template"` // 模板名称(可选)
|
||||
ShowInHome *bool `json:"show_in_home"` // 是否在首页展示(可选)
|
||||
Route *string `json:"route"` // 访问路由(可选)
|
||||
Password *string `json:"password"` // 简历密码(可选)
|
||||
}
|
||||
|
||||
// QuizQuestion 面试题题目模型
|
||||
type QuizQuestion struct {
|
||||
Type string `json:"type"` // 题目类型:mcq(选择题)、fill(填空题)、sa(简答题)、algo(算法设计题)
|
||||
Text string `json:"text"` // 题目内容
|
||||
Options []string `json:"options"` // 选项(选择题)
|
||||
Answer string `json:"answer"` // 参考答案
|
||||
Analysis string `json:"analysis"` // 答案解析(详细拓展)
|
||||
Score int `json:"score"` // 分值
|
||||
Keywords []string `json:"keywords"` // 关联关键词
|
||||
Difficulty string `json:"difficulty"` // 难度级别:入门、初级、中级、进阶、高级
|
||||
Category string `json:"category"` // 题目分类
|
||||
}
|
||||
|
||||
// QuizRecord 面试题历史记录模型
|
||||
type QuizRecord struct {
|
||||
ID string `json:"id"` // 记录唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
ResumeRoute string `json:"resume_route"` // 关联的简历路由
|
||||
Title string `json:"title"` // 答题记录标题
|
||||
Questions []QuizQuestion `json:"questions"` // 题目列表
|
||||
UserAnswers map[string]string `json:"user_answers"` // 用户答案
|
||||
Score float64 `json:"score"` // 得分
|
||||
CorrectCount int `json:"correct_count"` // 正确题数
|
||||
TotalCount int `json:"total_count"` // 总题数
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// FavoriteQuestion 收藏/错题本模型
|
||||
type FavoriteQuestion struct {
|
||||
ID string `json:"id"` // 记录唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
ResumeRoute string `json:"resume_route"` // 关联的简历路由
|
||||
Question QuizQuestion `json:"question"` // 题目内容
|
||||
UserAnswer string `json:"user_answer"` // 用户答案
|
||||
IsCorrect bool `json:"is_correct"` // 是否正确
|
||||
IsFavorite bool `json:"is_favorite"` // 是否收藏
|
||||
CreatedAt string `json:"created_at"` // 添加时间
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"resume-platform/pkg/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Parser interface {
|
||||
Parse(fileName string, reader io.Reader) (string, error)
|
||||
}
|
||||
|
||||
func NewParser(fileName string) (Parser, error) {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
return &PDFParser{}, nil
|
||||
case ".docx":
|
||||
return &DOCXParser{}, nil
|
||||
case ".xlsx":
|
||||
return &ExcelParser{}, nil
|
||||
case ".md", ".markdown":
|
||||
return &MarkdownParser{}, nil
|
||||
case ".txt":
|
||||
return &TextParser{}, nil
|
||||
default:
|
||||
return &TextParser{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ParseFile(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parser, err := NewParser(fileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
text, err := parser.Parse(fileName, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if text == "" && len(data) > 0 {
|
||||
text = extractTextFromBinary(data)
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func extractTextFromBinary(data []byte) string {
|
||||
content := string(data)
|
||||
var builder strings.Builder
|
||||
|
||||
for _, r := range content {
|
||||
if r >= 0x20 && r <= 0x7E || r == '\n' || r == '\r' || r == '\t' ||
|
||||
(r >= 0x4E00 && r <= 0x9FFF) || (r >= 0x3000 && r <= 0x303F) ||
|
||||
(r >= 0xFF00 && r <= 0xFFEF) {
|
||||
builder.WriteRune(r)
|
||||
} else if r > 0 {
|
||||
builder.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
return cleanText(builder.String())
|
||||
}
|
||||
|
||||
type TextParser struct{}
|
||||
|
||||
func (p *TextParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanText(string(data)), nil
|
||||
}
|
||||
|
||||
type MarkdownParser struct{}
|
||||
|
||||
func (p *MarkdownParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanText(string(data)), nil
|
||||
}
|
||||
|
||||
type PDFParser struct{}
|
||||
|
||||
func (p *PDFParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.HasPrefix(content, "%PDF-") {
|
||||
return "", fmt.Errorf("not a valid PDF file")
|
||||
}
|
||||
|
||||
text := extractTextFromPDF(content)
|
||||
return cleanText(text), nil
|
||||
}
|
||||
|
||||
func extractTextFromPDF(content string) string {
|
||||
var builder strings.Builder
|
||||
inTextBlock := false
|
||||
inStream := false
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if strings.Contains(line, "/Length") && !inStream {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "stream") {
|
||||
inStream = true
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "endstream") {
|
||||
inStream = false
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(line, "BT") {
|
||||
inTextBlock = true
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(line, "ET") {
|
||||
inTextBlock = false
|
||||
continue
|
||||
}
|
||||
|
||||
if inTextBlock && !inStream {
|
||||
if strings.HasPrefix(line, "(") && strings.HasSuffix(line, ")") {
|
||||
text := line[1 : len(line)-1]
|
||||
text = strings.ReplaceAll(text, "\\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\\r", "\r")
|
||||
text = strings.ReplaceAll(text, "\\t", "\t")
|
||||
text = strings.ReplaceAll(text, "\\(", "(")
|
||||
text = strings.ReplaceAll(text, "\\)", ")")
|
||||
text = strings.ReplaceAll(text, "\\/", "/")
|
||||
builder.WriteString(text)
|
||||
builder.WriteString(" ")
|
||||
} else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
content := line[1 : len(line)-1]
|
||||
parts := strings.Fields(content)
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") {
|
||||
text := part[1 : len(part)-1]
|
||||
text = strings.ReplaceAll(text, "\\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\\r", "\r")
|
||||
text = strings.ReplaceAll(text, "\\t", "\t")
|
||||
builder.WriteString(text)
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
type DOCXParser struct{}
|
||||
|
||||
func (p *DOCXParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG DOCXParser: file size: %d bytes\n", len(data))
|
||||
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open DOCX as zip: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG DOCXParser: zip contains %d files\n", len(zipReader.File))
|
||||
|
||||
var text strings.Builder
|
||||
foundDocument := false
|
||||
|
||||
for _, file := range zipReader.File {
|
||||
fmt.Printf("DEBUG DOCXParser: zip file: %s (size: %d)\n", file.Name, file.UncompressedSize64)
|
||||
|
||||
if file.Name == "word/document.xml" {
|
||||
foundDocument = true
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open document.xml: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
xmlData, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read document.xml: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG DOCXParser: document.xml size: %d bytes\n", len(xmlData))
|
||||
|
||||
parsedText := parseDOCXXML(string(xmlData))
|
||||
fmt.Printf("DEBUG DOCXParser: parsed text from document.xml: %d chars\n", len(parsedText))
|
||||
|
||||
text.WriteString(parsedText)
|
||||
} else if strings.HasPrefix(file.Name, "word/header") && strings.HasSuffix(file.Name, ".xml") {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
xmlData, _ := io.ReadAll(f)
|
||||
text.WriteString(parseDOCXXML(string(xmlData)))
|
||||
text.WriteString("\n")
|
||||
} else if strings.HasPrefix(file.Name, "word/footer") && strings.HasSuffix(file.Name, ".xml") {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
xmlData, _ := io.ReadAll(f)
|
||||
text.WriteString(parseDOCXXML(string(xmlData)))
|
||||
text.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDocument {
|
||||
fmt.Println("WARN DOCXParser: document.xml not found in zip")
|
||||
return cleanText(extractTextFromBinary(data)), nil
|
||||
}
|
||||
|
||||
result := cleanText(text.String())
|
||||
fmt.Printf("DEBUG DOCXParser: final result length: %d chars\n", len(result))
|
||||
|
||||
if len(result) > 0 {
|
||||
fmt.Printf("DEBUG DOCXParser: first 500 chars:\n%s\n", result[:utils.Min(len(result), 500)])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseDOCXXML(xmlContent string) string {
|
||||
fmt.Printf("DEBUG parseDOCXXML: input length: %d chars\n", len(xmlContent))
|
||||
|
||||
xmlContent = removeNamespacePrefixes(xmlContent)
|
||||
|
||||
allTexts := extractTextContent(xmlContent)
|
||||
|
||||
var builder strings.Builder
|
||||
for _, text := range allTexts {
|
||||
text = strings.TrimSpace(text)
|
||||
if text != "" {
|
||||
builder.WriteString(text)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
result := cleanText(builder.String())
|
||||
fmt.Printf("DEBUG parseDOCXXML: extracted %d text elements, result length: %d\n", len(allTexts), len(result))
|
||||
|
||||
if len(result) > 0 {
|
||||
fmt.Printf("DEBUG parseDOCXXML: first 500 chars:\n%s\n", result[:utils.Min(len(result), 500)])
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
fmt.Println("WARN: Structured parsing returned empty, using fallback")
|
||||
return cleanText(extractPlainTextFromXML(xmlContent))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func extractTextContent(xmlContent string) []string {
|
||||
var texts []string
|
||||
|
||||
inTag := false
|
||||
currentText := ""
|
||||
|
||||
for _, r := range xmlContent {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
if currentText != "" {
|
||||
texts = append(texts, currentText)
|
||||
currentText = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
currentText += string(r)
|
||||
}
|
||||
}
|
||||
|
||||
if currentText != "" {
|
||||
texts = append(texts, currentText)
|
||||
}
|
||||
|
||||
return texts
|
||||
}
|
||||
|
||||
func removeNamespacePrefixes(xmlContent string) string {
|
||||
prefixes := []string{"w:", "r:", "wp:", "wpc:", "wps:", "wpg:", "wpi:", "wne:", "mc:", "o:", "v:", "w10:", "wpc:", "wpg:", "wps:", "a:", "m:"}
|
||||
for _, prefix := range prefixes {
|
||||
xmlContent = strings.ReplaceAll(xmlContent, "<"+prefix, "<")
|
||||
xmlContent = strings.ReplaceAll(xmlContent, "</"+prefix, "</")
|
||||
xmlContent = strings.ReplaceAll(xmlContent, " "+prefix, " ")
|
||||
}
|
||||
return xmlContent
|
||||
}
|
||||
|
||||
func extractPlainTextFromXML(xmlContent string) string {
|
||||
var builder strings.Builder
|
||||
inTag := false
|
||||
|
||||
for _, r := range xmlContent {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
type ExcelParser struct{}
|
||||
|
||||
func (p *ExcelParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open XLSX as zip: %w", err)
|
||||
}
|
||||
|
||||
var text strings.Builder
|
||||
for _, file := range zipReader.File {
|
||||
if strings.HasPrefix(file.Name, "xl/worksheets/") && strings.HasSuffix(file.Name, ".xml") {
|
||||
sheetName := strings.TrimPrefix(file.Name, "xl/worksheets/sheet")
|
||||
sheetName = strings.TrimSuffix(sheetName, ".xml")
|
||||
text.WriteString("===" + sheetName + "===\n\n")
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open worksheet: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
xmlData, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read worksheet: %w", err)
|
||||
}
|
||||
|
||||
text.WriteString(parseXLSXXML(string(xmlData)))
|
||||
text.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return cleanText(text.String()), nil
|
||||
}
|
||||
|
||||
func parseXLSXXML(xmlContent string) string {
|
||||
type Cell struct {
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
type Row struct {
|
||||
Cells []Cell `xml:"c"`
|
||||
}
|
||||
type SheetData struct {
|
||||
Rows []Row `xml:"row"`
|
||||
}
|
||||
type Worksheet struct {
|
||||
SheetData SheetData `xml:"sheetData"`
|
||||
}
|
||||
|
||||
var ws Worksheet
|
||||
err := xml.Unmarshal([]byte(xmlContent), &ws)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, row := range ws.SheetData.Rows {
|
||||
var cells []string
|
||||
for _, cell := range row.Cells {
|
||||
text := strings.TrimSpace(cell.Text)
|
||||
if text != "" {
|
||||
cells = append(cells, text)
|
||||
}
|
||||
}
|
||||
if len(cells) > 0 {
|
||||
builder.WriteString(strings.Join(cells, "\t"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func cleanText(text string) string {
|
||||
return utils.CleanText(text)
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package prompt
|
||||
|
||||
import "fmt"
|
||||
|
||||
var (
|
||||
ResumeExtractor = `你是一位专业的中文简历信息提取专家。请仔细分析以下简历内容,全面提取所有信息并按指定格式输出。
|
||||
|
||||
【简历内容】
|
||||
%s
|
||||
|
||||
【提取要求】
|
||||
请全面分析文档内容,识别以下所有区块,并尽可能从已有信息中推断和补充缺失的字段:
|
||||
|
||||
1. 基本信息:
|
||||
- name: 姓名(通常在文档最开头,可能是最大的字体)
|
||||
- title: 当前职位/头衔(如"后端开发工程师"、"高级Java工程师")
|
||||
- email: 邮箱地址(包含@符号,可能在文档开头或结尾)
|
||||
- phone: 手机号码(11位数字,可能在文档开头或结尾)
|
||||
- location: 所在城市(如"北京"、"上海",可能在基本信息区域)
|
||||
- website: 个人网站或博客地址(包含http://或https://)
|
||||
- summary: 个人简介或自我评价(通常以"个人简介"、"自我评价"、"职业概述"等开头)
|
||||
- experience_years: 工作年限(从工作经历计算,填数字,如3、5、8)
|
||||
- job_target: 求职目标或意向岗位(如果没有明确说明,可从当前职位推断)
|
||||
- industry: 所属行业(如互联网、教育、金融、电商,从公司名称或描述推断)
|
||||
- core_advantages: 核心优势或个人亮点(从工作经历和项目经验中提炼)
|
||||
|
||||
2. 工作经历:
|
||||
- company: 公司名称
|
||||
- position: 职位名称
|
||||
- start_date: 开始时间(格式YYYY-MM)
|
||||
- end_date: 结束时间(格式YYYY-MM,"至今"或"当前"填"present")
|
||||
- description: 工作职责描述(详细描述日常工作内容)
|
||||
- highlights: 工作亮点或主要成就(用列表表示,从描述中提炼关键成果)
|
||||
- platforms: 负责的线上平台或产品名称(如有)
|
||||
|
||||
3. 教育背景:
|
||||
- school: 学校名称
|
||||
- degree: 学位(本科/硕士/博士/大专/高中等)
|
||||
- major: 专业名称
|
||||
- start_date: 入学时间(格式YYYY-MM)
|
||||
- end_date: 毕业时间(格式YYYY-MM)
|
||||
- gpa: GPA成绩(如有)
|
||||
|
||||
4. 专业技能:
|
||||
- name: 技能名称(如Java、Python、MySQL、Redis、Vue.js、Docker)
|
||||
- level: 熟练程度(入门/初级/中级/高级/精通,根据描述推断)
|
||||
- category: 技能分类(后端/前端/数据库/运维/移动端/人工智能/工具/其他)
|
||||
|
||||
5. 项目经验:
|
||||
- name: 项目名称
|
||||
- description: 项目描述(项目的目的、规模、主要功能)
|
||||
- tech_stack: 技术栈列表(从项目描述和工作经历中提取使用的技术)
|
||||
- url: 项目链接(如有)
|
||||
- highlights: 项目亮点(主要负责的工作、解决的问题)
|
||||
- achievements: 项目成果(量化的成果、获得的奖励等)
|
||||
- start_date: 开始时间(格式YYYY-MM)
|
||||
- end_date: 结束时间(格式YYYY-MM)
|
||||
|
||||
【数据补充规则】
|
||||
1. 如果工作年限没有明确说明,根据工作经历的时间跨度计算得出
|
||||
2. 如果求职目标没有明确说明,可从当前职位或工作经历推断
|
||||
3. 如果行业没有明确说明,从公司名称或工作描述推断
|
||||
4. 如果技能的熟练程度没有明确说明,根据使用频率和描述推断(如"精通"、"熟练"、"熟悉"、"了解")
|
||||
5. 如果技能分类不确定,根据技能性质归类(如Java属于后端,Vue.js属于前端,MySQL属于数据库)
|
||||
6. 如果项目的技术栈没有明确说明,从项目描述中提取使用的技术
|
||||
7. 如果项目亮点和成果没有明确说明,从项目描述中提炼关键贡献
|
||||
8. 如果核心优势没有明确说明,从工作经历和项目经验中提炼3-5个关键点
|
||||
|
||||
【关键词识别】
|
||||
- 基本信息区域关键词:姓名、电话、手机、邮箱、邮箱地址、地址、所在地、籍贯、个人简介、自我评价、职业概述、求职意向、工作年限
|
||||
- 工作经历区域关键词:工作经历、工作经验、职业经历、工作背景、公司、任职时间、工作职责、负责内容
|
||||
- 教育背景区域关键词:教育背景、学历、教育经历、毕业院校、学校、学位、专业
|
||||
- 技能区域关键词:专业技能、技能特长、技术能力、掌握技能、熟练掌握、了解、精通
|
||||
- 项目经验区域关键词:项目经验、项目经历、项目背景、项目名称、负责项目、参与项目
|
||||
|
||||
【重要提示】
|
||||
1. 请按照文档中的实际内容提取,不要遗漏任何信息
|
||||
2. 如果某个字段在简历中确实找不到,请填空字符串""或空数组[]
|
||||
3. 时间格式统一为YYYY-MM,"至今"或"当前"填"present"
|
||||
4. 工作年限只填数字,不要填"年"字
|
||||
5. 请确保提取的信息与文档内容完全一致
|
||||
6. 如果发现冲突信息,请以文档中最新的信息为准
|
||||
|
||||
【输出格式】
|
||||
请直接输出JSON,不要包含markdown代码块标记,格式如下:
|
||||
|
||||
{
|
||||
"basic_info": {
|
||||
"name": "",
|
||||
"title": "",
|
||||
"email": "",
|
||||
"phone": "",
|
||||
"location": "",
|
||||
"website": "",
|
||||
"summary": "",
|
||||
"experience_years": "",
|
||||
"job_target": "",
|
||||
"industry": "",
|
||||
"core_advantages": []
|
||||
},
|
||||
"experience": [
|
||||
{
|
||||
"company": "",
|
||||
"position": "",
|
||||
"start_date": "",
|
||||
"end_date": "",
|
||||
"description": "",
|
||||
"highlights": [],
|
||||
"platforms": []
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"school": "",
|
||||
"degree": "",
|
||||
"major": "",
|
||||
"start_date": "",
|
||||
"end_date": "",
|
||||
"gpa": ""
|
||||
}
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"name": "",
|
||||
"level": "",
|
||||
"category": ""
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "",
|
||||
"description": "",
|
||||
"tech_stack": [],
|
||||
"url": "",
|
||||
"highlights": [],
|
||||
"achievements": [],
|
||||
"start_date": "",
|
||||
"end_date": ""
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
RAGAnswer = `你是一位专业的简历分析助手。请根据以下上下文信息回答用户的问题。
|
||||
|
||||
【上下文信息】
|
||||
%s
|
||||
|
||||
【用户问题】
|
||||
%s
|
||||
|
||||
【回答要求】
|
||||
1. 优先使用上下文信息回答问题
|
||||
2. 如果上下文没有相关信息,可以使用你的内部知识
|
||||
3. 回答要准确、简洁,符合中文表达习惯
|
||||
4. 如果无法回答,请明确说明`
|
||||
|
||||
PolishSummary = `你是一位专业的简历优化专家。请优化以下个人简介,使其更加专业、简洁、有吸引力:
|
||||
|
||||
原文:
|
||||
%s
|
||||
|
||||
优化要求:
|
||||
1. 突出核心竞争力和独特价值
|
||||
2. 使用行动动词和量化成果
|
||||
3. 保持专业但不过于生硬
|
||||
4. 控制在150字以内
|
||||
5. 保持原意,不要编造信息
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`
|
||||
|
||||
PolishExperience = `你是一位专业的简历优化专家。请优化以下工作经历描述,使其更加专业、量化、有说服力:
|
||||
|
||||
公司:%s
|
||||
职位:%s
|
||||
原描述:
|
||||
%s
|
||||
|
||||
优化要求:
|
||||
1. 使用STAR法则(情境-任务-行动-结果)
|
||||
2. 突出量化成果和数据指标
|
||||
3. 使用专业术语和行业关键词
|
||||
4. 突出个人贡献而非团队成果
|
||||
5. 保持原意,不要编造信息
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`
|
||||
|
||||
PolishProject = `你是一位专业的简历优化专家。请优化以下项目描述,使其更加专业、突出技术能力和成果:
|
||||
|
||||
项目名称:%s
|
||||
原描述:
|
||||
%s
|
||||
|
||||
优化要求:
|
||||
1. 清晰描述技术架构和技术栈
|
||||
2. 突出个人技术贡献和解决的难题
|
||||
3. 量化项目成果和影响
|
||||
4. 使用专业技术术语
|
||||
5. 保持原意,不要编造信息
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`
|
||||
|
||||
PolishDescription = `你是一位专业的简历优化专家。请优化以下%s内容:
|
||||
|
||||
原文:
|
||||
%s
|
||||
|
||||
优化要求:
|
||||
1. 语言更加专业、精炼
|
||||
2. 突出重点和亮点
|
||||
3. 使用适当的行业术语
|
||||
4. 保持原意,不要编造信息
|
||||
5. 格式清晰易读
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`
|
||||
|
||||
DocumentAnalysis = `你是一位专业的文档分析助手。请根据以下文档内容回答用户问题。
|
||||
|
||||
文档内容:
|
||||
%s
|
||||
|
||||
用户问题:%s
|
||||
|
||||
回答要求:
|
||||
1. 仅基于提供的文档内容回答,不要编造信息
|
||||
2. 如果文档内容不足以回答问题,请明确说明
|
||||
3. 回答要简洁准确,避免冗长`
|
||||
|
||||
QuestionGeneration = `你是一位专业的技术面试官和出题专家。请根据以下简历内容和技术关键词,生成高质量的面试题目。
|
||||
|
||||
简历内容:
|
||||
%s
|
||||
|
||||
技术关键词:%s
|
||||
|
||||
题目类型:%s
|
||||
|
||||
生成规则(必须严格遵守,否则任务失败):
|
||||
1. 每种题目类型必须生成4道题目,不得少于4道,不得多于5道
|
||||
2. 如果包含4种题型(选择题、填空题、简答题、算法设计题),必须生成16道题目(4×4)
|
||||
3. 如果包含3种题型,必须生成12道题目(4×3)
|
||||
4. 如果包含2种题型,必须生成8道题目(4×2)
|
||||
5. 选择题必须有4个选项,正确答案必须是选项之一
|
||||
6. 填空题必须有明确的填空位置,用___表示
|
||||
7. 每道题必须有详细的解析,不少于50字
|
||||
8. 答案必须完整准确,代码题要有可运行的代码示例
|
||||
9. 题目难度在初级、入门、中级、进阶之间均匀分布
|
||||
10. 每道题至少包含2个技术关键词
|
||||
11. 输出格式必须为纯JSON数组,不要包含任何markdown标记、代码块标记或额外文字
|
||||
12. JSON字符串中所有双引号必须使用反斜杠转义
|
||||
|
||||
JSON格式要求(必须严格遵守,每种类型4题):
|
||||
[
|
||||
{
|
||||
"type": "mcq",
|
||||
"text": "选择题题目内容",
|
||||
"options": ["选项A", "选项B", "选项C", "选项D"],
|
||||
"answer": "正确答案(如A、B、C、D)",
|
||||
"analysis": "题目解析,直接给出解析内容",
|
||||
"score": 10,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "初级",
|
||||
"category": "分类名称"
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"text": "填空题题目内容(用___表示填空位置)",
|
||||
"answer": "正确答案",
|
||||
"analysis": "题目解析",
|
||||
"score": 5,
|
||||
"keywords": ["关键词1"],
|
||||
"difficulty": "入门",
|
||||
"category": "分类名称"
|
||||
},
|
||||
{
|
||||
"type": "sa",
|
||||
"text": "简答题题目内容",
|
||||
"answer": "参考答案",
|
||||
"analysis": "详细解析",
|
||||
"score": 15,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "中级",
|
||||
"category": "分类名称"
|
||||
},
|
||||
{
|
||||
"type": "algo",
|
||||
"text": "算法题题目内容",
|
||||
"answer": "代码答案",
|
||||
"analysis": "详细解析",
|
||||
"score": 20,
|
||||
"keywords": ["关键词1"],
|
||||
"difficulty": "进阶",
|
||||
"category": "算法"
|
||||
}
|
||||
]
|
||||
|
||||
请立即开始生成题目,严格按照上述规则输出JSON数组。如果题目数量不够或格式不正确,将被视为任务失败。`
|
||||
|
||||
QuestionGenerationForType = `你是一位专业的技术面试官和出题专家。题目和答案必须严谨专业,符合面试需求,不得出现错误或漏洞。
|
||||
|
||||
请根据以下简历内容和技术关键词,生成高质量的%s。
|
||||
|
||||
简历内容:
|
||||
%s
|
||||
|
||||
技术关键词:%s
|
||||
|
||||
生成规则(必须严格遵守,否则任务失败):
|
||||
1. 必须生成5道%s,不得少于5道
|
||||
2. 选择题必须有4个选项,答案字段必须只包含选项字母A、B、C或D,不能包含选项内容文字
|
||||
3. 选择题的选项必须清晰明确,互不重叠,只有一个正确答案
|
||||
4. 填空题必须有明确的填空位置,用___表示
|
||||
5. 每道题必须有详细的解析,不少于50字
|
||||
6. 答案必须完整准确,代码题要有可运行的代码示例
|
||||
7. 题目难度在初级、入门、中级、进阶之间均匀分布
|
||||
8. 每道题至少包含2个技术关键词
|
||||
9. 输出格式必须为纯JSON数组,不要包含任何markdown标记、代码块标记或额外文字
|
||||
10. JSON字符串中所有双引号必须使用反斜杠转义
|
||||
11. 确保题目和答案正确无误,避免出现逻辑漏洞或事实错误
|
||||
|
||||
JSON格式要求(必须严格遵守,生成5道%s):
|
||||
[
|
||||
%s
|
||||
]
|
||||
|
||||
请立即开始生成题目,严格按照上述规则输出JSON数组。`
|
||||
)
|
||||
|
||||
func BuildResumeExtractorPrompt(content string) string {
|
||||
return fmt.Sprintf(ResumeExtractor, content)
|
||||
}
|
||||
|
||||
func BuildRAGAnswerPrompt(context, query string) string {
|
||||
return fmt.Sprintf(RAGAnswer, context, query)
|
||||
}
|
||||
|
||||
func BuildPolishSummaryPrompt(original string) string {
|
||||
return fmt.Sprintf(PolishSummary, original)
|
||||
}
|
||||
|
||||
func BuildPolishExperiencePrompt(original, company, position string) string {
|
||||
return fmt.Sprintf(PolishExperience, company, position, original)
|
||||
}
|
||||
|
||||
func BuildPolishProjectPrompt(original, projectName string) string {
|
||||
return fmt.Sprintf(PolishProject, projectName, original)
|
||||
}
|
||||
|
||||
func BuildPolishDescriptionPrompt(original, fieldType string) string {
|
||||
var fieldName string
|
||||
switch fieldType {
|
||||
case "summary":
|
||||
fieldName = "个人简介"
|
||||
case "experience":
|
||||
fieldName = "工作经历"
|
||||
case "project":
|
||||
fieldName = "项目描述"
|
||||
case "education":
|
||||
fieldName = "教育背景"
|
||||
case "skill":
|
||||
fieldName = "技能描述"
|
||||
default:
|
||||
fieldName = "文本内容"
|
||||
}
|
||||
return fmt.Sprintf(PolishDescription, fieldName, original)
|
||||
}
|
||||
|
||||
func BuildDocumentAnalysisPrompt(context, question string) string {
|
||||
return fmt.Sprintf(DocumentAnalysis, context, question)
|
||||
}
|
||||
|
||||
func BuildQuestionGenerationPrompt(resumeText string, keywords []string, types []string) string {
|
||||
typeNames := map[string]string{
|
||||
"mcq": "选择题",
|
||||
"fill": "填空题",
|
||||
"sa": "简答题",
|
||||
"algo": "算法设计题",
|
||||
}
|
||||
|
||||
var typeDesc string
|
||||
for i, t := range types {
|
||||
if i > 0 {
|
||||
typeDesc += "、"
|
||||
}
|
||||
typeDesc += typeNames[t]
|
||||
}
|
||||
|
||||
return fmt.Sprintf(QuestionGeneration, resumeText, joinKeywords(keywords), typeDesc)
|
||||
}
|
||||
|
||||
func BuildQuestionGenerationPromptForType(resumeText string, keywords []string, qType string) string {
|
||||
typeNames := map[string]string{
|
||||
"mcq": "选择题",
|
||||
"fill": "填空题",
|
||||
"sa": "简答题",
|
||||
"algo": "算法设计题",
|
||||
}
|
||||
|
||||
typeName := typeNames[qType]
|
||||
if typeName == "" {
|
||||
typeName = qType
|
||||
}
|
||||
|
||||
var formatExample string
|
||||
switch qType {
|
||||
case "mcq":
|
||||
formatExample = `{
|
||||
"type": "mcq",
|
||||
"text": "题目内容",
|
||||
"options": ["选项A内容", "选项B内容", "选项C内容", "选项D内容"],
|
||||
"answer": "A",
|
||||
"analysis": "详细解析,不少于50字,说明每个选项为什么对或错",
|
||||
"score": 10,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "初级",
|
||||
"category": "分类名称"
|
||||
}`
|
||||
case "fill":
|
||||
formatExample = `{
|
||||
"type": "fill",
|
||||
"text": "题目内容(用___表示填空位置)",
|
||||
"answer": "正确答案",
|
||||
"analysis": "详细解析,不少于50字,解释答案的由来",
|
||||
"score": 5,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "入门",
|
||||
"category": "分类名称"
|
||||
}`
|
||||
case "sa":
|
||||
formatExample = `{
|
||||
"type": "sa",
|
||||
"text": "题目内容",
|
||||
"answer": "参考答案,完整准确",
|
||||
"analysis": "详细解析,不少于100字,深入解释原理",
|
||||
"score": 15,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "中级",
|
||||
"category": "分类名称"
|
||||
}`
|
||||
case "algo":
|
||||
formatExample = `{
|
||||
"type": "algo",
|
||||
"text": "题目内容",
|
||||
"answer": "完整可运行的代码示例",
|
||||
"analysis": "详细解析,不少于100字,解释算法思路和复杂度",
|
||||
"score": 20,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "进阶",
|
||||
"category": "算法"
|
||||
}`
|
||||
}
|
||||
|
||||
return fmt.Sprintf(QuestionGenerationForType, typeName, resumeText, joinKeywords(keywords), typeName, typeName, formatExample)
|
||||
}
|
||||
|
||||
func joinKeywords(keywords []string) string {
|
||||
var result string
|
||||
for i, kw := range keywords {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += kw
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package repository
|
||||
|
||||
import "resume-platform/internal/model"
|
||||
|
||||
// ResumeRepository 简历数据访问层接口,定义简历相关的数据操作方法
|
||||
type ResumeRepository interface {
|
||||
GetResume(id string) (*model.Resume, error)
|
||||
GetResumeByRoute(route string) (*model.Resume, error)
|
||||
RouteExists(route string) bool
|
||||
GetAllResumes() ([]*model.Resume, error)
|
||||
GetResumesByUserID(userID string) ([]*model.Resume, error)
|
||||
GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error)
|
||||
GetResumesWithFilter(page, pageSize int, userID, keyword, template string, showInHome *bool) ([]*model.Resume, int, error)
|
||||
CreateResume(resume *model.Resume) error
|
||||
UpdateResume(id string, update *model.ResumeUpdate) error
|
||||
DeleteResume(id string) error
|
||||
UpdateResumeUserID(resumeID, userID string) error
|
||||
Exists(id string) bool
|
||||
CreateUser(user *model.User) error
|
||||
GetUserByUsername(username string) (*model.User, error)
|
||||
GetUserByID(id string) (*model.User, error)
|
||||
GetAllUsers() ([]*model.User, error)
|
||||
GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error)
|
||||
UpdateUser(id string, user *model.User) error
|
||||
DeleteUser(id string) error
|
||||
GetPortfolioItems() ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error)
|
||||
GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItem(id uint) (*model.PortfolioItem, error)
|
||||
CreatePortfolioItem(item *model.PortfolioItem) error
|
||||
UpdatePortfolioItem(id uint, item *model.PortfolioItem) error
|
||||
DeletePortfolioItem(id uint) error
|
||||
CreateQuizRecord(record *model.QuizRecord) error
|
||||
GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error)
|
||||
GetQuizRecordsByUserID(userID string) ([]*model.QuizRecord, error)
|
||||
GetQuizRecordsByUserIDWithPagination(userID string, page, pageSize int) ([]*model.QuizRecord, int, error)
|
||||
GetQuizRecord(id string) (*model.QuizRecord, error)
|
||||
DeleteQuizRecord(id string) error
|
||||
CreateFavoriteQuestion(fav *model.FavoriteQuestion) error
|
||||
GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error)
|
||||
GetFavoriteQuestionsByUserID(userID string) ([]*model.FavoriteQuestion, error)
|
||||
DeleteFavoriteQuestion(id string) error
|
||||
ToggleFavorite(id string) error
|
||||
CreateDocument(doc *model.Document) error
|
||||
GetDocument(id, userID string) (*model.Document, error)
|
||||
GetDocumentByID(id string) (*model.Document, error)
|
||||
GetDocumentsByUserID(userID string) ([]*model.Document, error)
|
||||
UpdateDocument(doc *model.Document) error
|
||||
DeleteDocument(id, userID string) error
|
||||
CreateDocumentChunk(chunk *model.DocumentChunk) error
|
||||
GetDocumentChunksByUserID(userID string) ([]*model.DocumentChunk, error)
|
||||
GetAllDocumentChunks() ([]*model.DocumentChunk, error)
|
||||
GetDocumentChunksByDocumentID(documentID string) ([]*model.DocumentChunk, error)
|
||||
CreateEmbedding(embedding *model.Embedding) error
|
||||
GetEmbeddingByContentHash(contentHash string) (*model.Embedding, error)
|
||||
GetEmbeddingsByUserID(userID string) ([]*model.Embedding, error)
|
||||
GetAllEmbeddings() ([]*model.Embedding, error)
|
||||
DeleteEmbedding(id string) error
|
||||
DeleteEmbeddingsBySource(sourceType, sourceID string) error
|
||||
CreateChatHistory(history *model.ChatHistory) error
|
||||
GetChatHistoryByUserID(userID string) ([]*model.ChatHistory, error)
|
||||
GetAllMenus() ([]*model.Menu, error)
|
||||
CreateMenu(menu *model.Menu) error
|
||||
UpdateMenu(id string, menu *model.Menu) error
|
||||
DeleteMenu(id string) error
|
||||
GetSystemConfig() (*model.SystemConfig, error)
|
||||
CreateSystemConfig(config *model.SystemConfig) error
|
||||
UpdateSystemConfig(id string, config *model.SystemConfig) error
|
||||
CleanSystemConfig() error
|
||||
CreateLoginHistory(history *model.LoginHistory) error
|
||||
GetLoginHistory(page, pageSize int) ([]*model.LoginHistory, int, error)
|
||||
CreateNotification(n *model.Notification) error
|
||||
GetNotifications() ([]*model.Notification, error)
|
||||
GetUnreadNotificationCount() (int, error)
|
||||
MarkNotificationAsRead(id string) error
|
||||
MarkAllNotificationsAsRead() error
|
||||
DeleteAllNotifications() error
|
||||
DeleteNotification(id string) error
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// setupAdminRoutes 配置后台管理路由
|
||||
// 登录入口(需配置路径):
|
||||
// - GET /my-secret-admin-123/login 登录页面
|
||||
// - POST /my-secret-admin-123/login 登录验证
|
||||
// - GET /my-secret-admin-123/captcha 验证码
|
||||
//
|
||||
// 后台管理路由(登录后访问):
|
||||
// - GET /dashboard 后台首页(仪表盘)
|
||||
// - GET /dashboard/home 后台首页
|
||||
// - GET /dashboard/users 人员列表页
|
||||
// - GET /dashboard/user/:id/resume 用户简历管理页
|
||||
// - GET /dashboard/user/:id/portfolio 用户作品集管理页
|
||||
// - GET /dashboard/user/:id/quiz 用户面试题库页
|
||||
// - GET /dashboard/menus 菜单管理页
|
||||
// - GET /dashboard/settings 系统设置页
|
||||
// - GET /dashboard/logout 退出登录
|
||||
func (r *Router) setupAdminRoutes(engine *gin.Engine) {
|
||||
adminPath := r.cfg.Auth.AdminPath
|
||||
if adminPath == "" {
|
||||
adminPath = "/admin"
|
||||
}
|
||||
|
||||
fmt.Printf("Setting up admin login routes at: %s\n", adminPath)
|
||||
|
||||
loginGroup := engine.Group(adminPath)
|
||||
{
|
||||
loginGroup.GET("/login", r.adminHandler.LoginPage)
|
||||
loginGroup.POST("/login", r.adminHandler.Login)
|
||||
loginGroup.GET("/captcha", r.adminHandler.Captcha)
|
||||
}
|
||||
|
||||
dashboard := engine.Group("/dashboard")
|
||||
dashboard.Use(r.adminHandler.AuthMiddleware())
|
||||
{
|
||||
dashboard.GET("/", r.adminHandler.HomePage)
|
||||
dashboard.GET("/home", r.adminHandler.HomePage)
|
||||
|
||||
dashboard.GET("/users", r.adminHandler.UsersPage)
|
||||
dashboard.GET("/user", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusMovedPermanently, "/dashboard/users")
|
||||
})
|
||||
|
||||
dashboard.GET("/resumes", r.adminHandler.ResumesPage)
|
||||
dashboard.GET("/resume/add", r.adminHandler.ResumeAddPage)
|
||||
dashboard.GET("/resume/:id/edit", r.adminHandler.ResumeEditPageWithoutUser)
|
||||
dashboard.GET("/user/:id/resume", r.adminHandler.UserResumePage)
|
||||
dashboard.GET("/user/:id/resume/:resume_id/edit", r.adminHandler.ResumeEditPage)
|
||||
|
||||
dashboard.GET("/portfolios", r.adminHandler.PortfoliosPage)
|
||||
dashboard.GET("/user/:id/portfolio", r.adminHandler.UserPortfolioPage)
|
||||
dashboard.GET("/portfolio/add", r.adminHandler.PortfolioAddPage)
|
||||
dashboard.GET("/portfolio/:id/edit", r.adminHandler.PortfolioEditPage)
|
||||
|
||||
dashboard.GET("/quiz", r.adminHandler.QuizPage)
|
||||
dashboard.GET("/user/:id/quiz", r.adminHandler.UserQuizPage)
|
||||
dashboard.GET("/user/:id/quiz/generate", r.adminHandler.QuizGeneratePage)
|
||||
dashboard.GET("/user/:id/quiz/record/:record_id", r.adminHandler.QuizRecordDetailPage)
|
||||
dashboard.POST("/api/quiz/favorite/:id/toggle", r.adminHandler.ToggleFavoriteAPI)
|
||||
dashboard.DELETE("/api/quiz/favorite/:id", r.resumeHandler.DeleteFavoriteQuestionAPI)
|
||||
|
||||
dashboard.GET("/settings", r.adminHandler.SettingsPage)
|
||||
|
||||
dashboard.GET("/logout", r.adminHandler.Logout)
|
||||
|
||||
dashboard.GET("/api/user/:id", r.resumeHandler.GetUserAPI)
|
||||
dashboard.POST("/api/user", r.resumeHandler.CreateUserAPI)
|
||||
dashboard.PUT("/api/user/:id", r.resumeHandler.UpdateUserAPI)
|
||||
dashboard.DELETE("/api/user/:id", r.resumeHandler.DeleteUserAPI)
|
||||
|
||||
dashboard.GET("/api/users", r.resumeHandler.GetAllUsersAPI)
|
||||
|
||||
dashboard.GET("/api/settings", r.adminHandler.GetSettingsAPI)
|
||||
dashboard.POST("/api/settings", r.adminHandler.UpdateSettingsAPI)
|
||||
dashboard.POST("/api/settings/logo", r.adminHandler.UploadLogoAPI)
|
||||
dashboard.GET("/api/settings/system-info", r.adminHandler.SystemInfoAPI)
|
||||
dashboard.GET("/api/settings/login-history", r.adminHandler.LoginHistoryAPI)
|
||||
|
||||
dashboard.POST("/api/portfolio", r.resumeHandler.CreatePortfolioItemAPI)
|
||||
dashboard.GET("/api/portfolio/:id", r.resumeHandler.GetPortfolioItemAPI)
|
||||
dashboard.PUT("/api/portfolio/:id", r.resumeHandler.UpdatePortfolioItemAPI)
|
||||
dashboard.DELETE("/api/portfolio/:id", r.resumeHandler.DeletePortfolioItemAPI)
|
||||
|
||||
dashboard.POST("/api/resume/create", r.resumeHandler.AdminCreateResumeAPI)
|
||||
dashboard.POST("/api/resume/generate-from-document", r.resumeHandler.GenerateResumeFromDocumentAPI)
|
||||
dashboard.GET("/api/resume/:id", r.resumeHandler.GetResumeByIDAPI)
|
||||
dashboard.PUT("/api/resume/:id", r.resumeHandler.UpdateResumeByIDAPI)
|
||||
dashboard.PUT("/api/resume/:id/show_in_home", r.resumeHandler.UpdateResumeShowInHomeAPI)
|
||||
dashboard.PUT("/api/resume/:id/password", r.resumeHandler.UpdateResumePasswordAPI)
|
||||
dashboard.PUT("/api/resume/:id/user", r.resumeHandler.UpdateResumeUserIDAPI)
|
||||
dashboard.DELETE("/api/resume/:id", r.resumeHandler.DeleteResumeAPI)
|
||||
|
||||
dashboard.GET("/api/search", r.adminHandler.SearchAPI)
|
||||
dashboard.GET("/api/notifications", r.adminHandler.GetNotificationsAPI)
|
||||
dashboard.POST("/api/notifications/:id/read", r.adminHandler.MarkNotificationAsReadAPI)
|
||||
dashboard.POST("/api/notifications/read-all", r.adminHandler.MarkAllNotificationsAsReadAPI)
|
||||
dashboard.DELETE("/api/notifications", r.adminHandler.DeleteAllNotificationsAPI)
|
||||
dashboard.GET("/api/notifications/unread-count", r.adminHandler.GetUnreadNotificationCountAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupAPIRoutes 配置 API 路由,所有接口前缀为 /api
|
||||
// 按模块分组:
|
||||
// - /api/resume 简历相关接口
|
||||
// - /api/portfolio 作品集相关接口
|
||||
// - /api/user 用户相关接口
|
||||
// - /api/quiz 面试题库相关接口
|
||||
// - /api/ai AI 相关接口
|
||||
// - /api/document 文档相关接口
|
||||
// - /api/chat 聊天相关接口
|
||||
// - /api/system 系统配置相关接口
|
||||
// - /api/menu 菜单相关接口
|
||||
// - /api/im 实时聊天/IM相关接口
|
||||
func (r *Router) setupAPIRoutes(engine *gin.Engine) {
|
||||
api := engine.Group("/api")
|
||||
{
|
||||
r.setupResumeAPIRoutes(api)
|
||||
r.setupPortfolioAPIRoutes(api)
|
||||
r.setupUserAPIRoutes(api)
|
||||
r.setupQuizAPIRoutes(api)
|
||||
r.setupAIAPIRoutes(api)
|
||||
r.setupDocumentAPIRoutes(api)
|
||||
r.setupChatAPIRoutes(api)
|
||||
r.setupSystemAPIRoutes(api)
|
||||
r.setupIMRoutes(api)
|
||||
r.setupKnowledgeAPIRoutes(api)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupAIAPIRoutes 配置 AI 相关 API 路由
|
||||
func (r *Router) setupAIAPIRoutes(api *gin.RouterGroup) {
|
||||
aiGroup := api.Group("/ai")
|
||||
{
|
||||
aiGroup.POST("/polish", r.aiHandler.PolishText)
|
||||
aiGroup.GET("/provider", r.aiHandler.GetProviderInfo)
|
||||
|
||||
agentGroup := aiGroup.Group("/agent")
|
||||
{
|
||||
agentGroup.POST("/polish", r.aiHandler.AgentPolish)
|
||||
agentGroup.POST("/analyze", r.aiHandler.AgentAnalyze)
|
||||
agentGroup.POST("/generate-questions", r.aiHandler.GenerateQuestions)
|
||||
agentGroup.POST("/evaluate-answers", r.aiHandler.EvaluateAnswers)
|
||||
agentGroup.POST("/submit-answers", r.aiHandler.SubmitAnswers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupChatAPIRoutes 配置聊天相关 API 路由
|
||||
func (r *Router) setupChatAPIRoutes(api *gin.RouterGroup) {
|
||||
chatGroup := api.Group("/chat")
|
||||
{
|
||||
chatGroup.POST("", r.chatHandler.Chat)
|
||||
chatGroup.GET("/history", r.chatHandler.GetChatHistory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupDocumentAPIRoutes 配置文档相关 API 路由
|
||||
func (r *Router) setupDocumentAPIRoutes(api *gin.RouterGroup) {
|
||||
documentGroup := api.Group("/document")
|
||||
{
|
||||
documentGroup.POST("/upload", r.documentHandler.UploadDocument)
|
||||
documentGroup.GET("/list", r.documentHandler.GetDocuments)
|
||||
documentGroup.GET("/:id", r.documentHandler.GetDocument)
|
||||
documentGroup.DELETE("/:id", r.documentHandler.DeleteDocument)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func (r *Router) setupIMRoutes(api *gin.RouterGroup) {
|
||||
imGroup := api.Group("/im")
|
||||
{
|
||||
imGroup.GET("/ws", r.imHandler.WebSocket)
|
||||
imGroup.POST("/chat", r.imHandler.Chat)
|
||||
imGroup.POST("/chat/stream", r.imHandler.ChatStream)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func (r *Router) setupKnowledgeAPIRoutes(api *gin.RouterGroup) {
|
||||
if r.knowledgeHandler == nil {
|
||||
return
|
||||
}
|
||||
knowledgeGroup := api.Group("/knowledge")
|
||||
{
|
||||
knowledgeGroup.GET("/search", r.knowledgeHandler.Search)
|
||||
knowledgeGroup.GET("/stats", r.knowledgeHandler.GetStats)
|
||||
knowledgeGroup.POST("/docs", r.knowledgeHandler.AddDocument)
|
||||
knowledgeGroup.POST("/resume", r.knowledgeHandler.AddResume)
|
||||
knowledgeGroup.POST("/text", r.knowledgeHandler.AddText)
|
||||
knowledgeGroup.POST("/chat", r.knowledgeHandler.Chat)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupPortfolioAPIRoutes 配置作品集相关 API 路由
|
||||
func (r *Router) setupPortfolioAPIRoutes(api *gin.RouterGroup) {
|
||||
portfolioGroup := api.Group("/portfolio")
|
||||
{
|
||||
portfolioGroup.GET("", r.resumeHandler.GetPortfolioItemsAPI)
|
||||
portfolioGroup.POST("", r.resumeHandler.CreatePortfolioItemAPI)
|
||||
portfolioGroup.GET("/:id", r.resumeHandler.GetPortfolioItemAPI)
|
||||
portfolioGroup.PUT("/:id", r.resumeHandler.UpdatePortfolioItemAPI)
|
||||
portfolioGroup.DELETE("/:id", r.resumeHandler.DeletePortfolioItemAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupQuizAPIRoutes 配置面试题库相关 API 路由
|
||||
func (r *Router) setupQuizAPIRoutes(api *gin.RouterGroup) {
|
||||
quizGroup := api.Group("/quiz")
|
||||
{
|
||||
quizGroup.POST("/record", r.resumeHandler.CreateQuizRecordAPI)
|
||||
quizGroup.GET("/records", r.resumeHandler.GetQuizRecordsAPI)
|
||||
quizGroup.GET("/record/:id", r.resumeHandler.GetQuizRecordAPI)
|
||||
quizGroup.DELETE("/record/:id", r.resumeHandler.DeleteQuizRecordAPI)
|
||||
|
||||
quizGroup.POST("/favorite", r.resumeHandler.CreateFavoriteQuestionAPI)
|
||||
quizGroup.GET("/favorites", r.resumeHandler.GetFavoriteQuestionsAPI)
|
||||
quizGroup.DELETE("/favorite/:id", r.resumeHandler.DeleteFavoriteQuestionAPI)
|
||||
quizGroup.PUT("/favorite/:id/toggle", r.resumeHandler.ToggleFavoriteAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupResumeAPIRoutes 配置简历相关 API 路由
|
||||
func (r *Router) setupResumeAPIRoutes(api *gin.RouterGroup) {
|
||||
resumeGroup := api.Group("/resume")
|
||||
{
|
||||
resumeGroup.GET("", r.resumeHandler.GetResumeAPI)
|
||||
resumeGroup.POST("", r.resumeHandler.CreateResumeAPI)
|
||||
resumeGroup.PUT("", r.resumeHandler.UpdateResumeAPI)
|
||||
resumeGroup.DELETE("/:id", r.resumeHandler.DeleteResumeAPI)
|
||||
resumeGroup.GET("/list", r.resumeHandler.GetAllResumesAPI)
|
||||
resumeGroup.GET("/route-check", r.resumeHandler.RouteCheckAPI)
|
||||
resumeGroup.POST("/generate-from-document", r.resumeHandler.GenerateResumeFromDocumentAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupSystemAPIRoutes 配置系统配置和菜单相关 API 路由
|
||||
func (r *Router) setupSystemAPIRoutes(api *gin.RouterGroup) {
|
||||
systemGroup := api.Group("/system")
|
||||
{
|
||||
systemGroup.GET("/config", r.resumeHandler.GetSystemConfigAPI)
|
||||
systemGroup.POST("/config", r.resumeHandler.UpdateSystemConfigAPI)
|
||||
}
|
||||
|
||||
menuGroup := api.Group("/menu")
|
||||
{
|
||||
menuGroup.GET("/list", r.resumeHandler.GetAllMenusAPI)
|
||||
menuGroup.POST("", r.resumeHandler.CreateMenuAPI)
|
||||
menuGroup.PUT("/:id", r.resumeHandler.UpdateMenuAPI)
|
||||
menuGroup.DELETE("/:id", r.resumeHandler.DeleteMenuAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupUserAPIRoutes 配置用户相关 API 路由
|
||||
func (r *Router) setupUserAPIRoutes(api *gin.RouterGroup) {
|
||||
userGroup := api.Group("/user")
|
||||
{
|
||||
userGroup.POST("", r.resumeHandler.CreateUserAPI)
|
||||
userGroup.GET("/list", r.resumeHandler.GetAllUsersAPI)
|
||||
userGroup.GET("/:id", r.resumeHandler.GetUserAPI)
|
||||
userGroup.PUT("/:id", r.resumeHandler.UpdateUserAPI)
|
||||
userGroup.DELETE("/:id", r.resumeHandler.DeleteUserAPI)
|
||||
userGroup.GET("/resumes", r.resumeHandler.GetResumesByUserAPI)
|
||||
userGroup.GET("/portfolio", r.resumeHandler.GetPortfolioItemsByUserAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupFrontendRoutes 配置前端页面路由
|
||||
// 路由结构:
|
||||
// - / 产品首页,展示项目介绍和所有简历列表
|
||||
// - /:route 个人简历页面,如 /zhangsan
|
||||
// - /:route/portfolio 个人作品集页面,如 /zhangsan/portfolio
|
||||
// - /:route/contact 个人联系方式页面,如 /zhangsan/contact
|
||||
// - /sitemap.xml 站点地图XML,用于搜索引擎优化
|
||||
func (r *Router) setupFrontendRoutes(engine *gin.Engine) {
|
||||
engine.GET("/", r.resumeHandler.ShowHome)
|
||||
engine.GET("/sitemap.xml", r.resumeHandler.Sitemap)
|
||||
engine.GET("/:route", r.resumeHandler.ShowResume)
|
||||
engine.POST("/:route/verify-password", r.resumeHandler.VerifyPassword)
|
||||
engine.GET("/:route/portfolio", r.resumeHandler.ShowPortfolio)
|
||||
engine.GET("/:route/contact", r.resumeHandler.ShowContact)
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
c.HTML(404, "web/404", nil)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Package router 负责 HTTP 路由的注册与管理
|
||||
// 按功能模块分为前台路由、后台管理路由和 API 路由三类
|
||||
package router
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"resume-platform/internal/handler"
|
||||
"resume-platform/internal/middleware"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/pkg/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Router 路由管理器,持有各模块 Handler 实例和全局配置
|
||||
type Router struct {
|
||||
resumeHandler *handler.ResumeHandler
|
||||
adminHandler *handler.AdminHandler
|
||||
aiHandler *handler.AIHandler
|
||||
documentHandler *handler.DocumentHandler
|
||||
chatHandler *handler.ChatHandler
|
||||
imHandler *handler.IMHandler
|
||||
knowledgeHandler *handler.KnowledgeHandler
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewRouter 创建路由管理器实例
|
||||
// @param resumeHandler 简历相关处理器
|
||||
// @param adminHandler 后台管理相关处理器
|
||||
// @param aiHandler AI 相关处理器
|
||||
// @param documentHandler 文档相关处理器
|
||||
// @param chatHandler 聊天相关处理器
|
||||
// @param cfg 全局配置
|
||||
// @return *Router 路由管理器实例
|
||||
// @author sunct
|
||||
func NewRouter(resumeHandler *handler.ResumeHandler, adminHandler *handler.AdminHandler, aiHandler *handler.AIHandler, documentHandler *handler.DocumentHandler, chatHandler *handler.ChatHandler, imHandler *handler.IMHandler, knowledgeHandler *handler.KnowledgeHandler, cfg *config.Config) *Router {
|
||||
return &Router{
|
||||
resumeHandler: resumeHandler,
|
||||
adminHandler: adminHandler,
|
||||
aiHandler: aiHandler,
|
||||
documentHandler: documentHandler,
|
||||
chatHandler: chatHandler,
|
||||
imHandler: imHandler,
|
||||
knowledgeHandler: knowledgeHandler,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Setup 初始化 Gin 引擎并注册所有路由、中间件和模板函数
|
||||
// 注册顺序:中间件 → 模板函数 → 静态资源 → API 路由 → 后台路由 → 前台路由 → 404 处理
|
||||
// @return *gin.Engine 配置完成的 Gin 引擎
|
||||
// @author sunct
|
||||
func (r *Router) Setup() *gin.Engine {
|
||||
gin.SetMode(r.cfg.Server.Mode)
|
||||
engine := gin.New()
|
||||
|
||||
engine.Use(middleware.Tracing("resume-platform"))
|
||||
engine.Use(middleware.RequestLogger())
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
engine.SetFuncMap(template.FuncMap{
|
||||
"dict": dict,
|
||||
"set": set,
|
||||
"merge": merge,
|
||||
"slice": slice,
|
||||
"append": appendFunc,
|
||||
"substr": substr,
|
||||
"first": func(n int, s interface{}) interface{} {
|
||||
switch v := s.(type) {
|
||||
case []*model.Resume:
|
||||
if n > len(v) {
|
||||
return v
|
||||
}
|
||||
return v[:n]
|
||||
default:
|
||||
return s
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
engine.LoadHTMLGlob("templates/web/*.html")
|
||||
engine.Static("/static", "./static")
|
||||
|
||||
r.setupAPIRoutes(engine)
|
||||
r.setupAdminRoutes(engine)
|
||||
r.setupFrontendRoutes(engine)
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) > 10 && c.Request.URL.Path[:10] == "/dashboard" {
|
||||
r.adminHandler.DashboardNotFound(c)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
c.HTML(http.StatusNotFound, "404.html", nil)
|
||||
})
|
||||
|
||||
return engine
|
||||
}
|
||||
|
||||
// dict 模板函数,根据传入的键值对构建 map[string]interface{}
|
||||
// 传入参数必须为偶数个,按 key, value 交替排列
|
||||
// @param values 键值对列表,格式:key1, value1, key2, value2...
|
||||
// @return map[string]interface{} 构建的字典
|
||||
// @author sunct
|
||||
func dict(values ...interface{}) (map[string]interface{}, error) {
|
||||
if len(values)%2 != 0 {
|
||||
return nil, nil
|
||||
}
|
||||
m := make(map[string]interface{}, len(values)/2)
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
key, ok := values[i].(string)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
m[key] = values[i+1]
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// set 模板函数,向 map 中设置键值对
|
||||
// @param m 目标 map
|
||||
// @param key 键名
|
||||
// @param value 值
|
||||
// @return interface{} 始终返回 nil
|
||||
// @author sunct
|
||||
func set(m map[string]interface{}, key string, value interface{}) interface{} {
|
||||
m[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// merge 模板函数,将 src map 中的所有键值合并到 dest map
|
||||
// @param dest 目标 map
|
||||
// @param src 源 map
|
||||
// @return map[string]interface{} 合并后的 dest
|
||||
// @author sunct
|
||||
func merge(dest map[string]interface{}, src map[string]interface{}) map[string]interface{} {
|
||||
for k, v := range src {
|
||||
dest[k] = v
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
// slice 模板函数,将传入的多个参数组装为切片
|
||||
// @param values 任意数量的参数
|
||||
// @return []interface{} 参数切片
|
||||
// @author sunct
|
||||
func slice(values ...interface{}) []interface{} {
|
||||
return values
|
||||
}
|
||||
|
||||
// appendFunc 模板函数,向切片末尾追加一个元素
|
||||
// @param slice 目标切片
|
||||
// @param value 待追加元素
|
||||
// @return []interface{} 追加后的切片
|
||||
// @author sunct
|
||||
func appendFunc(slice []interface{}, value interface{}) []interface{} {
|
||||
return append(slice, value)
|
||||
}
|
||||
|
||||
// substr 模板函数,按 rune 截取字符串,支持中文等 Unicode 字符
|
||||
// @param s 源字符串
|
||||
// @param start 起始位置,支持负数(从末尾倒数)
|
||||
// @param length 可选,截取长度;不传则截取到末尾
|
||||
// @return string 截取后的子串
|
||||
// @author sunct
|
||||
func substr(s string, start int, length ...int) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
runeLen := len(runes)
|
||||
|
||||
startIdx := 0
|
||||
if start < 0 {
|
||||
startIdx = runeLen + start
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
} else {
|
||||
startIdx = start
|
||||
}
|
||||
|
||||
if startIdx >= runeLen {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(length) == 0 {
|
||||
return string(runes[startIdx:])
|
||||
}
|
||||
|
||||
endIdx := startIdx + length[0]
|
||||
if endIdx > runeLen {
|
||||
endIdx = runeLen
|
||||
}
|
||||
|
||||
return string(runes[startIdx:endIdx])
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package security 安全模块,提供验证码、登录限流等安全防护能力
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mojocn/base64Captcha"
|
||||
)
|
||||
|
||||
// CaptchaInstance 全局验证码实例
|
||||
var CaptchaInstance *base64Captcha.Captcha
|
||||
|
||||
// InitCaptcha 初始化数字验证码
|
||||
// @param width 验证码宽度
|
||||
// @param height 验证码高度
|
||||
// @author sunct
|
||||
func InitCaptcha(width, height int) {
|
||||
driver := base64Captcha.NewDriverDigit(
|
||||
height,
|
||||
width,
|
||||
4,
|
||||
0.7,
|
||||
80,
|
||||
)
|
||||
CaptchaInstance = base64Captcha.NewCaptcha(driver, base64Captcha.DefaultMemStore)
|
||||
}
|
||||
|
||||
// Verify 校验验证码答案
|
||||
// @param token 验证码令牌
|
||||
// @param code 用户输入的验证码
|
||||
// @return bool 是否验证通过
|
||||
// @author sunct
|
||||
func Verify(token, code string) bool {
|
||||
return base64Captcha.DefaultMemStore.Verify(token, code, true)
|
||||
}
|
||||
|
||||
// ServeCaptcha 生成并输出验证码图片(PNG格式)
|
||||
// @param w HTTP 响应写入器
|
||||
// @author sunct
|
||||
func ServeCaptcha(w http.ResponseWriter) {
|
||||
if CaptchaInstance == nil {
|
||||
InitCaptcha(160, 50)
|
||||
}
|
||||
|
||||
id, b64s, _ := CaptchaInstance.Generate()
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
w.Header().Set("X-Captcha-Token", id)
|
||||
|
||||
prefix := "data:image/png;base64,"
|
||||
if strings.HasPrefix(b64s, prefix) {
|
||||
b64s = strings.TrimPrefix(b64s, prefix)
|
||||
}
|
||||
|
||||
data, _ := base64.StdEncoding.DecodeString(b64s)
|
||||
w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginAttempt 登录尝试记录
|
||||
type LoginAttempt struct {
|
||||
Count int // 尝试次数
|
||||
LastTry time.Time // 最后尝试时间
|
||||
LockedUntil time.Time // 锁定截止时间
|
||||
}
|
||||
|
||||
// RateLimiter 登录限流器,基于 IP 防止暴力破解
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex // 互斥锁
|
||||
attempts map[string]*LoginAttempt // 尝试记录映射
|
||||
maxAttempts int // 最大尝试次数
|
||||
lockoutDuration time.Duration // 锁定时长
|
||||
}
|
||||
|
||||
// NewRateLimiter 创建登录限流器
|
||||
// @param maxAttempts 最大尝试次数
|
||||
// @param lockoutDuration 锁定时长(分钟)
|
||||
// @return *RateLimiter 限流器实例
|
||||
// @author sunct
|
||||
func NewRateLimiter(maxAttempts int, lockoutDuration int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
attempts: make(map[string]*LoginAttempt),
|
||||
maxAttempts: maxAttempts,
|
||||
lockoutDuration: time.Duration(lockoutDuration) * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 检查 IP 是否允许登录
|
||||
// @param ip 客户端IP
|
||||
// @return bool 是否允许
|
||||
// @return int 剩余尝试次数
|
||||
// @author sunct
|
||||
func (rl *RateLimiter) Allow(ip string) (bool, int) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if attempt, exists := rl.attempts[ip]; exists {
|
||||
if now.Before(attempt.LockedUntil) {
|
||||
return false, rl.maxAttempts - attempt.Count
|
||||
}
|
||||
|
||||
if now.Sub(attempt.LastTry) > 1*time.Hour {
|
||||
delete(rl.attempts, ip)
|
||||
return true, rl.maxAttempts
|
||||
}
|
||||
|
||||
if attempt.Count >= rl.maxAttempts {
|
||||
attempt.LockedUntil = now.Add(rl.lockoutDuration)
|
||||
return false, 0
|
||||
}
|
||||
}
|
||||
|
||||
return true, rl.maxAttempts
|
||||
}
|
||||
|
||||
// RecordAttempt 记录一次登录尝试
|
||||
// @param ip 客户端IP
|
||||
// @author sunct
|
||||
func (rl *RateLimiter) RecordAttempt(ip string) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if attempt, exists := rl.attempts[ip]; exists {
|
||||
attempt.Count++
|
||||
attempt.LastTry = now
|
||||
} else {
|
||||
rl.attempts[ip] = &LoginAttempt{
|
||||
Count: 1,
|
||||
LastTry: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 重置 IP 的登录尝试记录
|
||||
// @param ip 客户端IP
|
||||
// @author sunct
|
||||
func (rl *RateLimiter) Reset(ip string) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
delete(rl.attempts, ip)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
)
|
||||
|
||||
type AIService struct {
|
||||
provider ai.Provider
|
||||
}
|
||||
|
||||
func NewAIService(provider ai.Provider) *AIService {
|
||||
return &AIService{provider: provider}
|
||||
}
|
||||
|
||||
func (s *AIService) PolishSummary(ctx context.Context, original string) (string, error) {
|
||||
promptText := prompt.BuildPolishSummaryPrompt(original)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishExperience(ctx context.Context, original string, company string, position string) (string, error) {
|
||||
promptText := prompt.BuildPolishExperiencePrompt(original, company, position)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishProject(ctx context.Context, original string, projectName string) (string, error) {
|
||||
promptText := prompt.BuildPolishProjectPrompt(original, projectName)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishDescription(ctx context.Context, original string, fieldType string) (string, error) {
|
||||
promptText := prompt.BuildPolishDescriptionPrompt(original, fieldType)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) GetProviderName() string {
|
||||
return s.provider.GetName()
|
||||
}
|
||||
|
||||
func (s *AIService) Generate(ctx context.Context, prompt string) (string, error) {
|
||||
return s.provider.Generate(ctx, prompt)
|
||||
}
|
||||
|
||||
func (s *AIService) Embedding(ctx context.Context, text string) ([]float64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/pkg/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type QuestionGeneratorService struct {
|
||||
provider ai.Provider
|
||||
}
|
||||
|
||||
func NewQuestionGeneratorService(provider ai.Provider) *QuestionGeneratorService {
|
||||
return &QuestionGeneratorService{provider: provider}
|
||||
}
|
||||
|
||||
func (s *QuestionGeneratorService) GenerateQuestions(ctx context.Context, resumeText, keywordsStr, typesStr string) ([]map[string]interface{}, error) {
|
||||
if resumeText == "" && keywordsStr == "" {
|
||||
return nil, fmt.Errorf("resume_text or keywords is required")
|
||||
}
|
||||
|
||||
if resumeText == "" {
|
||||
resumeText = "基于关键词:" + keywordsStr
|
||||
}
|
||||
|
||||
techKeywords := extractTechKeywords(resumeText)
|
||||
if len(techKeywords) == 0 {
|
||||
techKeywords = []string{"HTML", "CSS", "JavaScript", "Go", "MySQL", "Redis"}
|
||||
}
|
||||
|
||||
if keywordsStr != "" {
|
||||
techKeywords = strings.Split(keywordsStr, ",")
|
||||
}
|
||||
|
||||
selectedTypes := parseTypes(typesStr)
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generating questions for keywords: %v, types: %v", techKeywords, selectedTypes)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
resultChan := make(chan []map[string]interface{}, len(selectedTypes))
|
||||
errChan := make(chan error, len(selectedTypes))
|
||||
|
||||
for _, qType := range selectedTypes {
|
||||
wg.Add(1)
|
||||
go func(t string) {
|
||||
defer wg.Done()
|
||||
|
||||
prompt := buildQuestionGenerationPromptForType(resumeText, techKeywords, t)
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generating %s questions...", t)
|
||||
|
||||
result, err := s.provider.Generate(ctx, prompt)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] AI generate failed for %s: %v", t, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := parseAIQuestionsWithCtx(ctx, result)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Parse failed for %s: %v", t, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
if len(questions) > 0 {
|
||||
resultChan <- questions
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generated %d %s questions", len(questions), t)
|
||||
}
|
||||
}(qType)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
var allQuestions []map[string]interface{}
|
||||
for questions := range resultChan {
|
||||
allQuestions = append(allQuestions, questions...)
|
||||
}
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Total generated %d questions", len(allQuestions))
|
||||
|
||||
if len(allQuestions) == 0 {
|
||||
return nil, fmt.Errorf("未能生成任何题目")
|
||||
}
|
||||
|
||||
return allQuestions, nil
|
||||
}
|
||||
|
||||
func buildQuestionGenerationPrompt(resumeText string, keywords []string, types []string) string {
|
||||
return prompt.BuildQuestionGenerationPrompt(resumeText, keywords, types)
|
||||
}
|
||||
|
||||
func buildQuestionGenerationPromptForType(resumeText string, keywords []string, qType string) string {
|
||||
return prompt.BuildQuestionGenerationPromptForType(resumeText, keywords, qType)
|
||||
}
|
||||
|
||||
func parseAIQuestionsWithCtx(ctx context.Context, result string) ([]map[string]interface{}, error) {
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] AI response length: %d", len(result))
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] AI response raw result (first 2000 chars): %s", substr(result, 0, 2000))
|
||||
|
||||
result = strings.ReplaceAll(result, "```json", "")
|
||||
result = strings.ReplaceAll(result, "```", "")
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
startIdx := strings.Index(result, "[")
|
||||
endIdx := strings.LastIndex(result, "]")
|
||||
if startIdx == -1 || endIdx == -1 || endIdx <= startIdx {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Cannot find JSON array boundaries")
|
||||
return nil, fmt.Errorf("无法找到JSON数组边界")
|
||||
}
|
||||
|
||||
jsonStr := result[startIdx : endIdx+1]
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Extracted JSON array (length=%d)", len(jsonStr))
|
||||
|
||||
var questions []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &questions); err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Failed to unmarshal as array: %v", err)
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Raw result that failed (first 500 chars): %s", substr(jsonStr, 0, 500))
|
||||
return nil, fmt.Errorf("AI返回的JSON格式不正确: %w", err)
|
||||
}
|
||||
|
||||
var validQuestions []map[string]interface{}
|
||||
for _, q := range questions {
|
||||
if !isValidQuestion(q) {
|
||||
logger.CtxWarnf(ctx, "[QuestionGenerator] Skipping invalid question: %v", q)
|
||||
continue
|
||||
}
|
||||
|
||||
q = normalizeQuestion(q)
|
||||
validQuestions = append(validQuestions, q)
|
||||
}
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Successfully parsed %d valid questions (filtered %d invalid)", len(validQuestions), len(questions)-len(validQuestions))
|
||||
|
||||
if len(validQuestions) == 0 {
|
||||
return nil, fmt.Errorf("未解析到有效题目")
|
||||
}
|
||||
|
||||
return validQuestions, nil
|
||||
}
|
||||
|
||||
func isValidQuestion(q map[string]interface{}) bool {
|
||||
if q == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
qType := fmt.Sprintf("%v", q["type"])
|
||||
text := fmt.Sprintf("%v", q["text"])
|
||||
answer := fmt.Sprintf("%v", q["answer"])
|
||||
|
||||
if qType == "" || text == "" || answer == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if qType == "mcq" {
|
||||
if options, ok := q["options"].([]interface{}); ok {
|
||||
if len(options) != 4 {
|
||||
return false
|
||||
}
|
||||
for _, opt := range options {
|
||||
if fmt.Sprintf("%v", opt) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
answer = strings.ToUpper(strings.TrimSpace(answer))
|
||||
if len(answer) != 1 || (answer < "A" || answer > "D") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if qType == "fill" {
|
||||
if !strings.Contains(text, "___") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if qType == "sa" || qType == "algo" {
|
||||
if len(answer) < 10 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
analysis := fmt.Sprintf("%v", q["analysis"])
|
||||
if len(analysis) < 30 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeQuestion(q map[string]interface{}) map[string]interface{} {
|
||||
qType := fmt.Sprintf("%v", q["type"])
|
||||
|
||||
if qType == "mcq" {
|
||||
answer := strings.TrimSpace(fmt.Sprintf("%v", q["answer"]))
|
||||
if len(answer) > 0 {
|
||||
q["answer"] = strings.ToUpper(answer[:1])
|
||||
}
|
||||
|
||||
if options, ok := q["options"].([]interface{}); ok {
|
||||
var optionStrings []string
|
||||
for _, opt := range options {
|
||||
optStr := strings.TrimSpace(fmt.Sprintf("%v", opt))
|
||||
optionStrings = append(optionStrings, optStr)
|
||||
}
|
||||
q["options"] = optionStrings
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := q["score"]; !ok {
|
||||
diff := fmt.Sprintf("%v", q["difficulty"])
|
||||
q["score"] = getScoreByDifficulty(diff)
|
||||
}
|
||||
if _, ok := q["category"]; !ok {
|
||||
q["category"] = "综合"
|
||||
}
|
||||
if _, ok := q["keywords"]; !ok {
|
||||
q["keywords"] = []string{}
|
||||
}
|
||||
if _, ok := q["options"]; !ok {
|
||||
q["options"] = []string{}
|
||||
}
|
||||
if _, ok := q["analysis"]; !ok {
|
||||
q["analysis"] = ""
|
||||
}
|
||||
if _, ok := q["difficulty"]; !ok {
|
||||
q["difficulty"] = "初级"
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func isSingleLetter(s string) bool {
|
||||
return len(s) == 1 && (s >= "A" && s <= "Z" || s >= "a" && s <= "z")
|
||||
}
|
||||
|
||||
func substr(s string, start, length int) string {
|
||||
if len(s) <= start {
|
||||
return ""
|
||||
}
|
||||
end := start + length
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func extractTechKeywords(text string) []string {
|
||||
techPatterns := []string{"Go", "Golang", "Java", "Python", "JavaScript", "TypeScript", "React", "Vue", "MySQL", "PostgreSQL", "Redis", "MongoDB", "Kafka", "Docker", "Kubernetes", "微服务", "分布式"}
|
||||
var result []string
|
||||
for _, pattern := range techPatterns {
|
||||
if strings.Contains(text, pattern) {
|
||||
result = append(result, pattern)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseTypes(typesStr string) []string {
|
||||
if typesStr == "" {
|
||||
return []string{"mcq", "fill", "sa", "algo"}
|
||||
}
|
||||
return strings.Split(typesStr, ",")
|
||||
}
|
||||
|
||||
func getScoreByDifficulty(difficulty string) int {
|
||||
switch difficulty {
|
||||
case "入门":
|
||||
return 5
|
||||
case "初级":
|
||||
return 10
|
||||
case "中级":
|
||||
return 15
|
||||
case "进阶":
|
||||
return 20
|
||||
case "高级":
|
||||
return 25
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/internal/service/ai"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ChatService interface {
|
||||
Chat(ctx context.Context, userID string, question string) (string, error)
|
||||
GetChatHistory(ctx context.Context, userID string) ([]*model.ChatHistory, error)
|
||||
DeleteChatHistory(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
type chatService struct {
|
||||
repo repository.ResumeRepository
|
||||
ai *ai.AIService
|
||||
}
|
||||
|
||||
func NewChatService(repo repository.ResumeRepository, ai *ai.AIService) ChatService {
|
||||
return &chatService{repo: repo, ai: ai}
|
||||
}
|
||||
|
||||
func (s *chatService) Chat(ctx context.Context, userID string, question string) (string, error) {
|
||||
chunks, err := s.repo.GetDocumentChunksByUserID(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(chunks) == 0 {
|
||||
return s.ai.Generate(ctx, question)
|
||||
}
|
||||
|
||||
var chunkList []model.ChunkWithScore
|
||||
for _, chunk := range chunks {
|
||||
chunkList = append(chunkList, model.ChunkWithScore{
|
||||
Content: chunk.Content,
|
||||
Metadata: chunk.Embedding,
|
||||
})
|
||||
}
|
||||
|
||||
var contextStr string
|
||||
for i, chunk := range chunkList[:min(5, len(chunkList))] {
|
||||
contextStr += fmt.Sprintf("[文档片段%d]\n%s\n\n", i+1, chunk.Content)
|
||||
}
|
||||
|
||||
promptText := prompt.BuildDocumentAnalysisPrompt(contextStr, question)
|
||||
|
||||
answer, err := s.ai.Generate(ctx, promptText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
history := &model.ChatHistory{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Question: question,
|
||||
Answer: answer,
|
||||
Context: contextStr,
|
||||
}
|
||||
s.repo.CreateChatHistory(history)
|
||||
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (s *chatService) GetChatHistory(ctx context.Context, userID string) ([]*model.ChatHistory, error) {
|
||||
return s.repo.GetChatHistoryByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *chatService) DeleteChatHistory(ctx context.Context, userID string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/internal/agent"
|
||||
"resume-platform/internal/ai"
|
||||
)
|
||||
|
||||
type IMService struct {
|
||||
aiProvider ai.Provider
|
||||
resumeAgent *agent.ResumeAgent
|
||||
}
|
||||
|
||||
func NewIMService(provider ai.Provider, resumeAgent *agent.ResumeAgent) *IMService {
|
||||
return &IMService{aiProvider: provider, resumeAgent: resumeAgent}
|
||||
}
|
||||
|
||||
func (s *IMService) GetAIProvider() ai.Provider {
|
||||
return s.aiProvider
|
||||
}
|
||||
|
||||
func (s *IMService) GetResumeAgent() *agent.ResumeAgent {
|
||||
return s.resumeAgent
|
||||
}
|
||||
|
||||
func (s *IMService) GenerateWithAgent(ctx context.Context, query string) (string, error) {
|
||||
if s.resumeAgent != nil {
|
||||
return s.resumeAgent.Run(ctx, query)
|
||||
}
|
||||
if s.aiProvider != nil {
|
||||
return s.aiProvider.Generate(ctx, query)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/parser"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/internal/service/knowledge"
|
||||
"resume-platform/internal/service/resume"
|
||||
"resume-platform/pkg/logger"
|
||||
)
|
||||
|
||||
type DocumentService interface {
|
||||
UploadDocument(ctx context.Context, userID string, fileName string, fileType string, fileSize int64, content string) (*model.Document, error)
|
||||
GetDocuments(ctx context.Context, userID string) ([]*model.Document, error)
|
||||
GetDocument(ctx context.Context, userID string, documentID string) (*model.Document, error)
|
||||
DeleteDocument(ctx context.Context, userID string, documentID string) error
|
||||
ProcessDocument(ctx context.Context, documentID string) error
|
||||
ParseAndGenerateResume(ctx context.Context, userID string, fileName string, content []byte) (*model.Resume, error)
|
||||
}
|
||||
|
||||
type documentService struct {
|
||||
repo repository.ResumeRepository
|
||||
resumeGenerator *resume.ResumeGeneratorService
|
||||
knowledgeBaseService *knowledge.KnowledgeBaseService
|
||||
}
|
||||
|
||||
func NewDocumentService(repo repository.ResumeRepository, resumeGenerator *resume.ResumeGeneratorService) DocumentService {
|
||||
return &documentService{repo: repo, resumeGenerator: resumeGenerator}
|
||||
}
|
||||
|
||||
func NewDocumentServiceWithKnowledge(repo repository.ResumeRepository, resumeGenerator *resume.ResumeGeneratorService, knowledgeBaseService *knowledge.KnowledgeBaseService) DocumentService {
|
||||
return &documentService{repo: repo, resumeGenerator: resumeGenerator, knowledgeBaseService: knowledgeBaseService}
|
||||
}
|
||||
|
||||
func (s *documentService) UploadDocument(ctx context.Context, userID string, fileName string, fileType string, fileSize int64, content string) (*model.Document, error) {
|
||||
document := &model.Document{
|
||||
ID: generateID(),
|
||||
UserID: userID,
|
||||
Name: fileName,
|
||||
Type: fileType,
|
||||
Size: fileSize,
|
||||
Content: content,
|
||||
Status: "uploading",
|
||||
}
|
||||
|
||||
err := s.repo.CreateDocument(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go s.ProcessDocument(ctx, document.ID)
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *documentService) GetDocuments(ctx context.Context, userID string) ([]*model.Document, error) {
|
||||
return s.repo.GetDocumentsByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *documentService) GetDocument(ctx context.Context, userID string, documentID string) (*model.Document, error) {
|
||||
return s.repo.GetDocument(documentID, userID)
|
||||
}
|
||||
|
||||
func (s *documentService) DeleteDocument(ctx context.Context, userID string, documentID string) error {
|
||||
return s.repo.DeleteDocument(documentID, userID)
|
||||
}
|
||||
|
||||
func (s *documentService) ProcessDocument(ctx context.Context, documentID string) error {
|
||||
document, err := s.repo.GetDocumentByID(documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if document.Content == "" {
|
||||
document.Status = "failed"
|
||||
document.ErrorMsg = "document content is empty"
|
||||
return s.repo.UpdateDocument(document)
|
||||
}
|
||||
|
||||
chunks := s.splitText(document.Content, 512)
|
||||
|
||||
for i, chunk := range chunks {
|
||||
metadata := map[string]interface{}{
|
||||
"document_id": document.ID,
|
||||
"chunk_index": i,
|
||||
"user_id": document.UserID,
|
||||
}
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: generateID(),
|
||||
DocumentID: document.ID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: string(metadataJSON),
|
||||
}
|
||||
|
||||
err := s.repo.CreateDocumentChunk(documentChunk)
|
||||
if err != nil {
|
||||
document.Status = "failed"
|
||||
document.ErrorMsg = err.Error()
|
||||
s.repo.UpdateDocument(document)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if s.knowledgeBaseService != nil {
|
||||
err = s.knowledgeBaseService.AddDocument(ctx, document.ID, document.Content, document.UserID)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to add document to knowledge base: %v", err)
|
||||
} else {
|
||||
logger.Infof("Document %s added to knowledge base", document.ID)
|
||||
}
|
||||
}
|
||||
|
||||
document.Status = "processed"
|
||||
return s.repo.UpdateDocument(document)
|
||||
}
|
||||
|
||||
func (s *documentService) splitText(text string, chunkSize int) []string {
|
||||
var chunks []string
|
||||
words := []rune(text)
|
||||
|
||||
for i := 0; i < len(words); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(words) {
|
||||
end = len(words)
|
||||
}
|
||||
chunks = append(chunks, string(words[i:end]))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func (s *documentService) ParseAndGenerateResume(ctx context.Context, userID string, fileName string, content []byte) (*model.Resume, error) {
|
||||
documentText, err := parser.ParseFile(fileName, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to parse document %s: %v", fileName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if documentText == "" {
|
||||
return nil, fmt.Errorf("document content is empty after parsing")
|
||||
}
|
||||
|
||||
resume, err := s.resumeGenerator.GenerateFromDocument(ctx, documentText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resume.UserID = userID
|
||||
fmt.Printf("DEBUG document.go: resume.UserID set to: '%s'\n", resume.UserID)
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
)
|
||||
|
||||
type EmbeddingService struct {
|
||||
aiProvider ai.Provider
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewEmbeddingService(provider ai.Provider, repo repository.ResumeRepository) *EmbeddingService {
|
||||
return &EmbeddingService{aiProvider: provider, repo: repo}
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) Generate(ctx context.Context, text string) ([]float64, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("empty text")
|
||||
}
|
||||
|
||||
contentHash := s.hashContent(text)
|
||||
|
||||
cachedEmbedding, err := s.repo.GetEmbeddingByContentHash(contentHash)
|
||||
if err == nil && cachedEmbedding != nil && cachedEmbedding.Embedding != "" {
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(cachedEmbedding.Embedding), &embedding); err == nil && len(embedding) > 0 {
|
||||
return embedding, nil
|
||||
}
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`请将以下文本转换为向量嵌入。直接输出JSON数组,不要包含任何其他内容。
|
||||
|
||||
文本内容:
|
||||
%s`, text)
|
||||
|
||||
result, err := s.aiProvider.Generate(ctx, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = cleanEmbeddingResult(result)
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(result), &embedding); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse embedding: %w", err)
|
||||
}
|
||||
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) GenerateAndStore(ctx context.Context, text, sourceType, sourceID, sourceName, userID string) ([]float64, error) {
|
||||
embedding, err := s.Generate(ctx, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentHash := s.hashContent(text)
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
err = s.repo.CreateEmbedding(&model.Embedding{
|
||||
ID: uuid.New().String(),
|
||||
ContentHash: contentHash,
|
||||
Embedding: string(embeddingJSON),
|
||||
SourceType: sourceType,
|
||||
SourceID: sourceID,
|
||||
SourceName: sourceName,
|
||||
UserID: userID,
|
||||
})
|
||||
|
||||
return embedding, err
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) BatchGenerate(ctx context.Context, texts []string) ([][]float64, error) {
|
||||
var results [][]float64
|
||||
for _, text := range texts {
|
||||
embedding, err := s.Generate(ctx, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, embedding)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) hashContent(content string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(content))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func cleanEmbeddingResult(result string) string {
|
||||
result = trimString(result)
|
||||
|
||||
if idx := findFirstIndex(result, '['); idx >= 0 {
|
||||
result = result[idx:]
|
||||
}
|
||||
if idx := findLastIndex(result, ']'); idx >= 0 {
|
||||
result = result[:idx+1]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func trimString(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func findFirstIndex(s string, c byte) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findLastIndex(s string, c byte) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/pkg/logger"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type KnowledgeBaseService struct {
|
||||
embeddingService *EmbeddingService
|
||||
vectorService *VectorService
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewKnowledgeBaseService(embeddingService *EmbeddingService, vectorService *VectorService, repo repository.ResumeRepository) *KnowledgeBaseService {
|
||||
return &KnowledgeBaseService{
|
||||
embeddingService: embeddingService,
|
||||
vectorService: vectorService,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddDocument(ctx context.Context, documentID, content, userID string) error {
|
||||
if content == "" {
|
||||
return fmt.Errorf("empty content")
|
||||
}
|
||||
|
||||
chunks := s.chunkContent(content, 500, 50)
|
||||
|
||||
doc, _ := s.repo.GetDocumentByID(documentID)
|
||||
sourceName := documentID[:8]
|
||||
if doc != nil && doc.Name != "" {
|
||||
sourceName = doc.Name
|
||||
}
|
||||
|
||||
for i, chunk := range chunks {
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: uuid.New().String(),
|
||||
DocumentID: documentID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: "",
|
||||
}
|
||||
|
||||
err := s.vectorService.StoreChunk(ctx, documentChunk, userID, sourceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("Added %d chunks to document %s", len(chunks), documentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddResume(ctx context.Context, resumeID string, resume *model.Resume) error {
|
||||
if resume == nil {
|
||||
return fmt.Errorf("nil resume")
|
||||
}
|
||||
|
||||
resumeText := s.serializeResume(resume)
|
||||
if resumeText == "" {
|
||||
return fmt.Errorf("empty resume content")
|
||||
}
|
||||
|
||||
chunks := s.chunkContent(resumeText, 500, 50)
|
||||
sourceName := resume.BasicInfo.Name
|
||||
if sourceName == "" {
|
||||
sourceName = resumeID[:8]
|
||||
}
|
||||
|
||||
for i, chunk := range chunks {
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: uuid.New().String(),
|
||||
DocumentID: resumeID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: "",
|
||||
}
|
||||
|
||||
err := s.vectorService.StoreChunk(ctx, documentChunk, resume.UserID, sourceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := s.vectorService.Store(ctx, resumeText, "resume", resumeID, sourceName, resume.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof("Added resume %s to knowledge base with %d chunks", resumeID, len(chunks))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddText(ctx context.Context, text, sourceType, sourceID, sourceName, userID string) error {
|
||||
if text == "" {
|
||||
return fmt.Errorf("empty text")
|
||||
}
|
||||
|
||||
return s.vectorService.Store(ctx, text, sourceType, sourceID, sourceName, userID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) Retrieve(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
return s.vectorService.SearchWithChunks(ctx, query, topK, userID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) RetrieveAll(ctx context.Context, query string, topK int) ([]model.ChunkWithScore, error) {
|
||||
return s.vectorService.Search(ctx, query, topK, "")
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) DeleteDocument(ctx context.Context, documentID string) error {
|
||||
err := s.repo.DeleteEmbeddingsBySource("document", documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.vectorService.DeleteDocumentChunks(ctx, documentID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) DeleteResume(ctx context.Context, resumeID string) error {
|
||||
return s.repo.DeleteEmbeddingsBySource("resume", resumeID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) GetStats(ctx context.Context, userID string) (map[string]int, error) {
|
||||
var stats = map[string]int{}
|
||||
|
||||
if userID != "" {
|
||||
embeddings, err := s.repo.GetEmbeddingsByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["embeddings"] = len(embeddings)
|
||||
|
||||
chunks, err := s.repo.GetDocumentChunksByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["chunks"] = len(chunks)
|
||||
} else {
|
||||
embeddings, err := s.repo.GetAllEmbeddings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["embeddings"] = len(embeddings)
|
||||
|
||||
chunks, err := s.repo.GetAllDocumentChunks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["chunks"] = len(chunks)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) chunkContent(content string, chunkSize, overlap int) []string {
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
|
||||
var chunks []string
|
||||
start := 0
|
||||
contentLen := len(content)
|
||||
|
||||
for start < contentLen {
|
||||
end := start + chunkSize
|
||||
if end > contentLen {
|
||||
end = contentLen
|
||||
}
|
||||
|
||||
if end < contentLen {
|
||||
for i := end; i > start && i > start+chunkSize-overlap; i-- {
|
||||
c := rune(content[i])
|
||||
if c == '\n' || c == ';' || c == '\r' {
|
||||
end = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(content[start:end])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
|
||||
start = end - overlap
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start >= contentLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) serializeResume(resume *model.Resume) string {
|
||||
var builder strings.Builder
|
||||
|
||||
if resume.BasicInfo.Name != "" {
|
||||
builder.WriteString("姓名:")
|
||||
builder.WriteString(resume.BasicInfo.Name)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Title != "" {
|
||||
builder.WriteString("职位:")
|
||||
builder.WriteString(resume.BasicInfo.Title)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Email != "" {
|
||||
builder.WriteString("邮箱:")
|
||||
builder.WriteString(resume.BasicInfo.Email)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Phone != "" {
|
||||
builder.WriteString("电话:")
|
||||
builder.WriteString(resume.BasicInfo.Phone)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Location != "" {
|
||||
builder.WriteString("所在地:")
|
||||
builder.WriteString(resume.BasicInfo.Location)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Summary != "" {
|
||||
builder.WriteString("个人简介:")
|
||||
builder.WriteString(resume.BasicInfo.Summary)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.JobTarget != "" {
|
||||
builder.WriteString("求职目标:")
|
||||
builder.WriteString(resume.BasicInfo.JobTarget)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(resume.Experience) > 0 {
|
||||
builder.WriteString("\n【工作经历】\n")
|
||||
for _, exp := range resume.Experience {
|
||||
builder.WriteString("公司:")
|
||||
builder.WriteString(exp.Company)
|
||||
builder.WriteString("\n职位:")
|
||||
builder.WriteString(exp.Position)
|
||||
builder.WriteString("\n时间:")
|
||||
builder.WriteString(exp.StartDate)
|
||||
if exp.EndDate != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(exp.EndDate)
|
||||
}
|
||||
builder.WriteString("\n职责:")
|
||||
builder.WriteString(exp.Description)
|
||||
builder.WriteString("\n")
|
||||
if len(exp.Highlights) > 0 {
|
||||
builder.WriteString("亮点:")
|
||||
builder.WriteString(strings.Join(exp.Highlights, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Education) > 0 {
|
||||
builder.WriteString("\n【教育背景】\n")
|
||||
for _, edu := range resume.Education {
|
||||
builder.WriteString("学校:")
|
||||
builder.WriteString(edu.School)
|
||||
builder.WriteString("\n学位:")
|
||||
builder.WriteString(edu.Degree)
|
||||
builder.WriteString("\n专业:")
|
||||
builder.WriteString(edu.Major)
|
||||
builder.WriteString("\n时间:")
|
||||
builder.WriteString(edu.StartDate)
|
||||
if edu.EndDate != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(edu.EndDate)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Skills) > 0 {
|
||||
builder.WriteString("\n【专业技能】\n")
|
||||
for _, skill := range resume.Skills {
|
||||
builder.WriteString(skill.Name)
|
||||
if skill.Level != "" {
|
||||
builder.WriteString("(")
|
||||
builder.WriteString(skill.Level)
|
||||
builder.WriteString(")")
|
||||
}
|
||||
if skill.Category != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(skill.Category)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Projects) > 0 {
|
||||
builder.WriteString("\n【项目经验】\n")
|
||||
for _, proj := range resume.Projects {
|
||||
builder.WriteString("项目名称:")
|
||||
builder.WriteString(proj.Name)
|
||||
builder.WriteString("\n描述:")
|
||||
builder.WriteString(proj.Description)
|
||||
builder.WriteString("\n")
|
||||
if len(proj.TechStack) > 0 {
|
||||
builder.WriteString("技术栈:")
|
||||
builder.WriteString(strings.Join(proj.TechStack, "、"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if len(proj.Highlights) > 0 {
|
||||
builder.WriteString("亮点:")
|
||||
builder.WriteString(strings.Join(proj.Highlights, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if len(proj.Achievements) > 0 {
|
||||
builder.WriteString("成果:")
|
||||
builder.WriteString(strings.Join(proj.Achievements, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
result := builder.String()
|
||||
if len(result) > 15000 {
|
||||
result = result[:15000]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) BuildIndex(ctx context.Context) error {
|
||||
documents, err := s.repo.GetAllDocumentChunks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, chunk := range documents {
|
||||
if chunk.Embedding == "" && chunk.Content != "" {
|
||||
embedding, err := s.embeddingService.Generate(ctx, chunk.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
chunk.Embedding = string(embeddingJSON)
|
||||
err = s.repo.CreateDocumentChunk(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RAGService struct {
|
||||
aiProvider ai.Provider
|
||||
knowledgeBaseService *KnowledgeBaseService
|
||||
}
|
||||
|
||||
func NewRAGService(provider ai.Provider, knowledgeBaseService *KnowledgeBaseService) *RAGService {
|
||||
return &RAGService{aiProvider: provider, knowledgeBaseService: knowledgeBaseService}
|
||||
}
|
||||
|
||||
func (s *RAGService) Generate(ctx context.Context, query string, userID string) (string, error) {
|
||||
contextResults, err := s.knowledgeBaseService.Retrieve(ctx, query, 5, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var contextStr string
|
||||
for _, result := range contextResults {
|
||||
contextStr += fmt.Sprintf("【来源:%s】\n%s\n\n", result.SourceName, result.Content)
|
||||
}
|
||||
|
||||
if contextStr == "" {
|
||||
return s.aiProvider.Generate(ctx, query)
|
||||
}
|
||||
|
||||
promptText := prompt.BuildRAGAnswerPrompt(contextStr, query)
|
||||
|
||||
return s.aiProvider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *RAGService) GenerateWithContext(ctx context.Context, query string, context []string) (string, error) {
|
||||
contextStr := strings.Join(context, "\n\n")
|
||||
|
||||
promptText := prompt.BuildRAGAnswerPrompt(contextStr, query)
|
||||
|
||||
return s.aiProvider.Generate(ctx, promptText)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type VectorService struct {
|
||||
embeddingService *EmbeddingService
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewVectorService(embeddingService *EmbeddingService, repo repository.ResumeRepository) *VectorService {
|
||||
return &VectorService{embeddingService: embeddingService, repo: repo}
|
||||
}
|
||||
|
||||
func (s *VectorService) Store(ctx context.Context, content, sourceType, sourceID, sourceName, userID string) error {
|
||||
_, err := s.embeddingService.GenerateAndStore(ctx, content, sourceType, sourceID, sourceName, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VectorService) StoreChunk(ctx context.Context, chunk *model.DocumentChunk, userID string, sourceName string) error {
|
||||
if chunk.Content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
embedding, err := s.embeddingService.Generate(ctx, chunk.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
chunk.Embedding = string(embeddingJSON)
|
||||
|
||||
err = s.repo.CreateDocumentChunk(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentHash := s.embeddingService.hashContent(chunk.Content)
|
||||
err = s.repo.CreateEmbedding(&model.Embedding{
|
||||
ID: chunk.ID,
|
||||
ContentHash: contentHash,
|
||||
Embedding: string(embeddingJSON),
|
||||
SourceType: "document",
|
||||
SourceID: chunk.DocumentID,
|
||||
SourceName: sourceName,
|
||||
UserID: userID,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VectorService) Search(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
queryEmbedding, err := s.embeddingService.Generate(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allEmbeddings []*model.Embedding
|
||||
if userID != "" {
|
||||
allEmbeddings, err = s.repo.GetEmbeddingsByUserID(userID)
|
||||
} else {
|
||||
allEmbeddings, err = s.repo.GetAllEmbeddings()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []model.ChunkWithScore
|
||||
for _, emb := range allEmbeddings {
|
||||
if emb.Embedding == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(emb.Embedding), &embedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
score := cosineSimilarity(queryEmbedding, embedding)
|
||||
if score > 0.3 {
|
||||
results = append(results, model.ChunkWithScore{
|
||||
Content: emb.SourceName,
|
||||
Score: score,
|
||||
SourceID: emb.SourceID,
|
||||
SourceName: emb.SourceName,
|
||||
SourceType: emb.SourceType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
if len(results) > topK {
|
||||
results = results[:topK]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *VectorService) SearchWithChunks(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
queryEmbedding, err := s.embeddingService.Generate(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allChunks []*model.DocumentChunk
|
||||
if userID != "" {
|
||||
allChunks, err = s.repo.GetDocumentChunksByUserID(userID)
|
||||
} else {
|
||||
allChunks, err = s.repo.GetAllDocumentChunks()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []model.ChunkWithScore
|
||||
for _, chunk := range allChunks {
|
||||
if chunk.Embedding == "" || chunk.Content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(chunk.Embedding), &embedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
score := cosineSimilarity(queryEmbedding, embedding)
|
||||
if score > 0.3 {
|
||||
doc, _ := s.repo.GetDocumentByID(chunk.DocumentID)
|
||||
sourceName := ""
|
||||
if doc != nil {
|
||||
sourceName = doc.Name
|
||||
}
|
||||
results = append(results, model.ChunkWithScore{
|
||||
Content: chunk.Content,
|
||||
Score: score,
|
||||
SourceID: chunk.DocumentID,
|
||||
SourceName: sourceName,
|
||||
SourceType: "document",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
if len(results) > topK {
|
||||
results = results[:topK]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *VectorService) Delete(ctx context.Context, sourceType, sourceID string) error {
|
||||
return s.repo.DeleteEmbeddingsBySource(sourceType, sourceID)
|
||||
}
|
||||
|
||||
func (s *VectorService) DeleteDocumentChunks(ctx context.Context, documentID string) error {
|
||||
_, err := s.repo.GetDocumentChunksByDocumentID(documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dotProduct, magA, magB float64
|
||||
for i := range a {
|
||||
dotProduct += a[i] * b[i]
|
||||
magA += a[i] * a[i]
|
||||
magB += b[i] * b[i]
|
||||
}
|
||||
|
||||
if magA == 0 || magB == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dotProduct / (math.Sqrt(magA) * math.Sqrt(magB))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package portfolio
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
)
|
||||
|
||||
type PortfolioService interface {
|
||||
GetPortfolioItems() ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error)
|
||||
GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItem(id uint) (*model.PortfolioItem, error)
|
||||
CreatePortfolioItem(item *model.PortfolioItem) error
|
||||
UpdatePortfolioItem(id uint, item *model.PortfolioItem) error
|
||||
DeletePortfolioItem(id uint) error
|
||||
}
|
||||
|
||||
type portfolioService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewPortfolioService(repo repository.ResumeRepository) PortfolioService {
|
||||
return &portfolioService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItems() ([]*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItems()
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error) {
|
||||
return s.repo.GetPortfolioItemsWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItemsByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItem(id uint) (*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItem(id)
|
||||
}
|
||||
|
||||
func (s *portfolioService) CreatePortfolioItem(item *model.PortfolioItem) error {
|
||||
return s.repo.CreatePortfolioItem(item)
|
||||
}
|
||||
|
||||
func (s *portfolioService) UpdatePortfolioItem(id uint, item *model.PortfolioItem) error {
|
||||
return s.repo.UpdatePortfolioItem(id, item)
|
||||
}
|
||||
|
||||
func (s *portfolioService) DeletePortfolioItem(id uint) error {
|
||||
return s.repo.DeletePortfolioItem(id)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package quiz
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type QuizService interface {
|
||||
CreateQuizRecord(record *model.QuizRecord) error
|
||||
GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error)
|
||||
GetQuizRecord(id string) (*model.QuizRecord, error)
|
||||
DeleteQuizRecord(id string) error
|
||||
CreateFavoriteQuestion(fav *model.FavoriteQuestion) error
|
||||
GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error)
|
||||
DeleteFavoriteQuestion(id string) error
|
||||
ToggleFavorite(id string) error
|
||||
}
|
||||
|
||||
type quizService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewQuizService(repo repository.ResumeRepository) QuizService {
|
||||
return &quizService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *quizService) CreateQuizRecord(record *model.QuizRecord) error {
|
||||
if record.ID == "" {
|
||||
record.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateQuizRecord(record)
|
||||
}
|
||||
|
||||
func (s *quizService) GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error) {
|
||||
return s.repo.GetQuizRecordsByResume(resumeRoute)
|
||||
}
|
||||
|
||||
func (s *quizService) GetQuizRecord(id string) (*model.QuizRecord, error) {
|
||||
return s.repo.GetQuizRecord(id)
|
||||
}
|
||||
|
||||
func (s *quizService) DeleteQuizRecord(id string) error {
|
||||
return s.repo.DeleteQuizRecord(id)
|
||||
}
|
||||
|
||||
func (s *quizService) CreateFavoriteQuestion(fav *model.FavoriteQuestion) error {
|
||||
if fav.ID == "" {
|
||||
fav.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateFavoriteQuestion(fav)
|
||||
}
|
||||
|
||||
func (s *quizService) GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error) {
|
||||
return s.repo.GetFavoriteQuestions(resumeRoute, isFavorite, isCorrect)
|
||||
}
|
||||
|
||||
func (s *quizService) DeleteFavoriteQuestion(id string) error {
|
||||
return s.repo.DeleteFavoriteQuestion(id)
|
||||
}
|
||||
|
||||
func (s *quizService) ToggleFavorite(id string) error {
|
||||
return s.repo.ToggleFavorite(id)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package resume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/pkg/logger"
|
||||
"resume-platform/pkg/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ResumeGeneratorService struct {
|
||||
aiProvider ai.Provider
|
||||
}
|
||||
|
||||
func NewResumeGeneratorService(provider ai.Provider) *ResumeGeneratorService {
|
||||
return &ResumeGeneratorService{aiProvider: provider}
|
||||
}
|
||||
|
||||
func (s *ResumeGeneratorService) GenerateFromDocument(ctx context.Context, documentContent string) (*model.Resume, error) {
|
||||
if s.aiProvider == nil {
|
||||
return nil, fmt.Errorf("AI provider not configured")
|
||||
}
|
||||
|
||||
cleanedContent := cleanDocumentContent(documentContent)
|
||||
if cleanedContent == "" {
|
||||
return nil, fmt.Errorf("document content is empty after cleaning")
|
||||
}
|
||||
|
||||
maxContentLength := 15000
|
||||
if len(cleanedContent) > maxContentLength {
|
||||
cleanedContent = truncateContent(cleanedContent, maxContentLength)
|
||||
}
|
||||
|
||||
return s.extractAllSections(ctx, cleanedContent)
|
||||
}
|
||||
|
||||
func (s *ResumeGeneratorService) extractAllSections(ctx context.Context, content string) (*model.Resume, error) {
|
||||
promptText := prompt.BuildResumeExtractorPrompt(content)
|
||||
|
||||
result, err := s.aiProvider.Generate(ctx, promptText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleanedResult := utils.CleanAIResponse(result)
|
||||
|
||||
var rawData struct {
|
||||
BasicInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Location string `json:"location"`
|
||||
Website string `json:"website"`
|
||||
Summary string `json:"summary"`
|
||||
ExperienceYears interface{} `json:"experience_years"`
|
||||
JobTarget string `json:"job_target"`
|
||||
Industry string `json:"industry"`
|
||||
CoreAdvantages []interface{} `json:"core_advantages"`
|
||||
} `json:"basic_info"`
|
||||
Experience []struct {
|
||||
Company string `json:"company"`
|
||||
Position string `json:"position"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Description string `json:"description"`
|
||||
Highlights []interface{} `json:"highlights"`
|
||||
Platforms []interface{} `json:"platforms"`
|
||||
} `json:"experience"`
|
||||
Education []struct {
|
||||
School string `json:"school"`
|
||||
Degree string `json:"degree"`
|
||||
Major string `json:"major"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
GPA string `json:"gpa"`
|
||||
} `json:"education"`
|
||||
Skills []struct {
|
||||
Name string `json:"name"`
|
||||
Level string `json:"level"`
|
||||
Category string `json:"category"`
|
||||
} `json:"skills"`
|
||||
Projects []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
TechStack []interface{} `json:"tech_stack"`
|
||||
URL string `json:"url"`
|
||||
Highlights []interface{} `json:"highlights"`
|
||||
Achievements []interface{} `json:"achievements"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(cleanedResult), &rawData); err != nil {
|
||||
logger.Errorf("Failed to parse AI result: %v, raw result: %s", err, cleanedResult)
|
||||
return nil, fmt.Errorf("failed to parse AI result: %w", err)
|
||||
}
|
||||
|
||||
coreAdvantages := make([]string, 0, len(rawData.BasicInfo.CoreAdvantages))
|
||||
for _, item := range rawData.BasicInfo.CoreAdvantages {
|
||||
coreAdvantages = append(coreAdvantages, utils.ConvertToString(item))
|
||||
}
|
||||
|
||||
experience := make([]model.Experience, 0, len(rawData.Experience))
|
||||
for _, exp := range rawData.Experience {
|
||||
if exp.Company == "" && exp.Position == "" {
|
||||
continue
|
||||
}
|
||||
highlights := make([]string, 0, len(exp.Highlights))
|
||||
for _, item := range exp.Highlights {
|
||||
highlights = append(highlights, utils.ConvertToString(item))
|
||||
}
|
||||
platforms := make([]string, 0, len(exp.Platforms))
|
||||
for _, item := range exp.Platforms {
|
||||
platforms = append(platforms, utils.ConvertToString(item))
|
||||
}
|
||||
experience = append(experience, model.Experience{
|
||||
Company: exp.Company,
|
||||
Position: exp.Position,
|
||||
StartDate: exp.StartDate,
|
||||
EndDate: exp.EndDate,
|
||||
Description: exp.Description,
|
||||
Highlights: highlights,
|
||||
Platforms: platforms,
|
||||
})
|
||||
}
|
||||
|
||||
education := make([]model.Education, 0, len(rawData.Education))
|
||||
for _, edu := range rawData.Education {
|
||||
if edu.School == "" {
|
||||
continue
|
||||
}
|
||||
education = append(education, model.Education{
|
||||
School: edu.School,
|
||||
Degree: edu.Degree,
|
||||
Major: edu.Major,
|
||||
StartDate: edu.StartDate,
|
||||
EndDate: edu.EndDate,
|
||||
GPA: edu.GPA,
|
||||
})
|
||||
}
|
||||
|
||||
skills := make([]model.Skill, 0, len(rawData.Skills))
|
||||
for _, skill := range rawData.Skills {
|
||||
if skill.Name == "" {
|
||||
continue
|
||||
}
|
||||
skills = append(skills, model.Skill{
|
||||
Name: skill.Name,
|
||||
Level: skill.Level,
|
||||
Category: skill.Category,
|
||||
})
|
||||
}
|
||||
|
||||
projects := make([]model.Project, 0, len(rawData.Projects))
|
||||
for _, proj := range rawData.Projects {
|
||||
if proj.Name == "" && proj.Description == "" {
|
||||
continue
|
||||
}
|
||||
techStack := make([]string, 0, len(proj.TechStack))
|
||||
for _, item := range proj.TechStack {
|
||||
techStack = append(techStack, utils.ConvertToString(item))
|
||||
}
|
||||
highlights := make([]string, 0, len(proj.Highlights))
|
||||
for _, item := range proj.Highlights {
|
||||
highlights = append(highlights, utils.ConvertToString(item))
|
||||
}
|
||||
achievements := make([]string, 0, len(proj.Achievements))
|
||||
for _, item := range proj.Achievements {
|
||||
achievements = append(achievements, utils.ConvertToString(item))
|
||||
}
|
||||
projects = append(projects, model.Project{
|
||||
Name: proj.Name,
|
||||
Description: proj.Description,
|
||||
TechStack: techStack,
|
||||
URL: proj.URL,
|
||||
Highlights: highlights,
|
||||
Achievements: achievements,
|
||||
StartDate: proj.StartDate,
|
||||
EndDate: proj.EndDate,
|
||||
ShowInResume: false,
|
||||
})
|
||||
}
|
||||
|
||||
resume := &model.Resume{
|
||||
ID: uuid.New().String(),
|
||||
Route: generateRouteFromName(rawData.BasicInfo.Name),
|
||||
BasicInfo: model.BasicInfo{
|
||||
Name: rawData.BasicInfo.Name,
|
||||
Title: rawData.BasicInfo.Title,
|
||||
Email: rawData.BasicInfo.Email,
|
||||
Phone: rawData.BasicInfo.Phone,
|
||||
Location: rawData.BasicInfo.Location,
|
||||
Website: rawData.BasicInfo.Website,
|
||||
Summary: rawData.BasicInfo.Summary,
|
||||
ExperienceYears: utils.ConvertToString(rawData.BasicInfo.ExperienceYears),
|
||||
JobTarget: rawData.BasicInfo.JobTarget,
|
||||
Industry: rawData.BasicInfo.Industry,
|
||||
CoreAdvantages: coreAdvantages,
|
||||
},
|
||||
Experience: experience,
|
||||
Education: education,
|
||||
Skills: skills,
|
||||
Projects: projects,
|
||||
Template: "modern",
|
||||
ShowInHome: false,
|
||||
}
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
|
||||
func cleanDocumentContent(content string) string {
|
||||
return utils.CleanText(content)
|
||||
}
|
||||
|
||||
func generateRouteFromName(name string) string {
|
||||
if name == "" {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
route := ""
|
||||
for _, c := range name {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
||||
route += string(c)
|
||||
}
|
||||
}
|
||||
if route == "" {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
return strings.ToLower(route)
|
||||
}
|
||||
|
||||
func truncateContent(content string, maxLength int) string {
|
||||
if len(content) <= maxLength {
|
||||
return content
|
||||
}
|
||||
|
||||
truncated := content[:maxLength]
|
||||
|
||||
for i := len(truncated) - 1; i >= 0; i-- {
|
||||
c := rune(truncated[i])
|
||||
if c == '。' || c == '!' || c == '?' || c == ';' ||
|
||||
c == '.' || c == '!' || c == '?' || c == ';' ||
|
||||
c == '\n' || c == '\r' {
|
||||
truncated = truncated[:i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return truncated + "\n\n[内容已截断,仅保留关键信息]"
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package resume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ResumeService interface {
|
||||
GetResume(id string) (*model.Resume, error)
|
||||
GetResumeByRoute(route string) (*model.Resume, error)
|
||||
RouteExists(route string) bool
|
||||
GetAllResumes() ([]*model.Resume, error)
|
||||
GetResumesByUserID(userID string) ([]*model.Resume, error)
|
||||
GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error)
|
||||
GetResumeWithMask(id string) (*model.Resume, error)
|
||||
CreateResume(resume *model.Resume) (*model.Resume, error)
|
||||
CreateResumeWithRoute(route string, resume *model.Resume) (*model.Resume, error)
|
||||
UpdateResume(id string, update *model.ResumeUpdate) (*model.Resume, error)
|
||||
DeleteResume(id string) error
|
||||
UpdateResumeUserID(resumeID, userID string) error
|
||||
InitDefaultResume() (*model.Resume, error)
|
||||
}
|
||||
|
||||
type resumeService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewResumeService(repo repository.ResumeRepository) ResumeService {
|
||||
return &resumeService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResume(id string) (*model.Resume, error) {
|
||||
return s.repo.GetResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumeByRoute(route string) (*model.Resume, error) {
|
||||
return s.repo.GetResumeByRoute(route)
|
||||
}
|
||||
|
||||
func (s *resumeService) RouteExists(route string) bool {
|
||||
return s.repo.RouteExists(route)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetAllResumes() ([]*model.Resume, error) {
|
||||
return s.repo.GetAllResumes()
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error) {
|
||||
return s.repo.GetResumesWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *resumeService) CreateResume(resume *model.Resume) (*model.Resume, error) {
|
||||
if resume.ID == "" {
|
||||
resume.ID = uuid.New().String()
|
||||
}
|
||||
if resume.Template == "" {
|
||||
resume.Template = "modern"
|
||||
}
|
||||
err := s.repo.CreateResume(resume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(resume.ID)
|
||||
}
|
||||
|
||||
func (s *resumeService) CreateResumeWithRoute(route string, resume *model.Resume) (*model.Resume, error) {
|
||||
if s.repo.RouteExists(route) {
|
||||
return nil, errors.New("route already exists")
|
||||
}
|
||||
|
||||
if resume.ID == "" {
|
||||
resume.ID = uuid.New().String()
|
||||
}
|
||||
if resume.Template == "" {
|
||||
resume.Template = "modern"
|
||||
}
|
||||
resume.Route = route
|
||||
|
||||
err := s.repo.CreateResume(resume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(resume.ID)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumesByUserID(userID string) ([]*model.Resume, error) {
|
||||
return s.repo.GetResumesByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *resumeService) UpdateResume(id string, update *model.ResumeUpdate) (*model.Resume, error) {
|
||||
if !s.repo.Exists(id) {
|
||||
return nil, errors.New("resume not found")
|
||||
}
|
||||
err := s.repo.UpdateResume(id, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) DeleteResume(id string) error {
|
||||
if !s.repo.Exists(id) {
|
||||
return errors.New("resume not found")
|
||||
}
|
||||
return s.repo.DeleteResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) UpdateResumeUserID(resumeID, userID string) error {
|
||||
if !s.repo.Exists(resumeID) {
|
||||
return errors.New("resume not found")
|
||||
}
|
||||
return s.repo.UpdateResumeUserID(resumeID, userID)
|
||||
}
|
||||
|
||||
func (s *resumeService) InitDefaultResume() (*model.Resume, error) {
|
||||
defaultID := "default"
|
||||
if s.repo.Exists(defaultID) {
|
||||
return s.repo.GetResume(defaultID)
|
||||
}
|
||||
|
||||
defaultResume := &model.Resume{
|
||||
ID: defaultID,
|
||||
Route: defaultID,
|
||||
Template: "modern",
|
||||
BasicInfo: model.BasicInfo{
|
||||
Name: "张三",
|
||||
Title: "全栈开发工程师",
|
||||
Email: "zhangsan@example.com",
|
||||
Phone: "13800138000",
|
||||
Location: "北京市",
|
||||
Website: "https://zhangsan.dev",
|
||||
Summary: "5年以上软件开发经验,专注于Web全栈开发,熟悉Go、React、Vue等技术栈。热爱技术,善于解决复杂问题。",
|
||||
Avatar: "",
|
||||
},
|
||||
Experience: []model.Experience{
|
||||
{
|
||||
Company: "某知名互联网公司",
|
||||
Position: "高级开发工程师",
|
||||
StartDate: "2022-01",
|
||||
EndDate: "至今",
|
||||
Description: "负责核心业务系统的架构设计与开发",
|
||||
Highlights: []string{"主导微服务架构改造", "优化系统性能提升50%", "带领5人团队完成多个重要项目"},
|
||||
},
|
||||
{
|
||||
Company: "创业公司",
|
||||
Position: "技术负责人",
|
||||
StartDate: "2019-06",
|
||||
EndDate: "2021-12",
|
||||
Description: "从0到1搭建技术团队和产品",
|
||||
Highlights: []string{"建立完整技术体系", "打造百万级用户产品", "获得天使轮融资"},
|
||||
},
|
||||
},
|
||||
Education: []model.Education{
|
||||
{
|
||||
School: "清华大学",
|
||||
Degree: "本科",
|
||||
Major: "计算机科学与技术",
|
||||
StartDate: "2015-09",
|
||||
EndDate: "2019-06",
|
||||
GPA: "3.8/4.0",
|
||||
},
|
||||
},
|
||||
Skills: []model.Skill{
|
||||
{Name: "Go", Level: "expert", Category: "后端"},
|
||||
{Name: "Python", Level: "advanced", Category: "后端"},
|
||||
{Name: "React", Level: "advanced", Category: "前端"},
|
||||
{Name: "Vue", Level: "advanced", Category: "前端"},
|
||||
{Name: "MySQL", Level: "advanced", Category: "数据库"},
|
||||
{Name: "Redis", Level: "intermediate", Category: "数据库"},
|
||||
{Name: "Docker", Level: "advanced", Category: "运维"},
|
||||
{Name: "Kubernetes", Level: "intermediate", Category: "运维"},
|
||||
},
|
||||
Projects: []model.Project{
|
||||
{
|
||||
Name: "电商平台",
|
||||
Description: "基于微服务架构的B2C电商平台",
|
||||
TechStack: []string{"Go", "React", "MySQL", "Redis", "Kubernetes"},
|
||||
URL: "https://github.com/example/ecommerce",
|
||||
Highlights: []string{"日订单量突破10万", "系统可用性99.9%", "支持百万级并发"},
|
||||
},
|
||||
{
|
||||
Name: "任务管理系统",
|
||||
Description: "团队协作任务管理工具",
|
||||
TechStack: []string{"Vue", "Node.js", "MongoDB"},
|
||||
URL: "https://github.com/example/taskmanager",
|
||||
Highlights: []string{"500+企业客户", "月活跃用户10万"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.repo.CreateResume(defaultResume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return defaultResume, nil
|
||||
}
|
||||
|
||||
func maskPhone(phone string) string {
|
||||
re := regexp.MustCompile(`(\d{3})\d{4}(\d{4})`)
|
||||
return re.ReplaceAllString(phone, "$1****$2")
|
||||
}
|
||||
|
||||
func maskEmail(email string) string {
|
||||
re := regexp.MustCompile(`(\w{1,3})[\w.]*@`)
|
||||
return re.ReplaceAllString(email, "$1***@")
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumeWithMask(id string) (*model.Resume, error) {
|
||||
resume, err := s.repo.GetResume(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resume.BasicInfo.Phone = maskPhone(resume.BasicInfo.Phone)
|
||||
resume.BasicInfo.Email = maskEmail(resume.BasicInfo.Email)
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SystemService interface {
|
||||
GetAllMenus() ([]*model.Menu, error)
|
||||
CreateMenu(menu *model.Menu) error
|
||||
UpdateMenu(id string, menu *model.Menu) error
|
||||
DeleteMenu(id string) error
|
||||
GetSystemConfig() (*model.SystemConfig, error)
|
||||
CreateSystemConfig(config *model.SystemConfig) error
|
||||
UpdateSystemConfig(id string, config *model.SystemConfig) error
|
||||
InitDefaultMenus() error
|
||||
}
|
||||
|
||||
type systemService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewSystemService(repo repository.ResumeRepository) SystemService {
|
||||
return &systemService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *systemService) GetAllMenus() ([]*model.Menu, error) {
|
||||
return s.repo.GetAllMenus()
|
||||
}
|
||||
|
||||
func (s *systemService) CreateMenu(menu *model.Menu) error {
|
||||
if menu.ID == "" {
|
||||
menu.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateMenu(menu)
|
||||
}
|
||||
|
||||
func (s *systemService) UpdateMenu(id string, menu *model.Menu) error {
|
||||
return s.repo.UpdateMenu(id, menu)
|
||||
}
|
||||
|
||||
func (s *systemService) DeleteMenu(id string) error {
|
||||
return s.repo.DeleteMenu(id)
|
||||
}
|
||||
|
||||
func (s *systemService) GetSystemConfig() (*model.SystemConfig, error) {
|
||||
return s.repo.GetSystemConfig()
|
||||
}
|
||||
|
||||
func (s *systemService) CreateSystemConfig(config *model.SystemConfig) error {
|
||||
if config.ID == "" {
|
||||
config.ID = "default"
|
||||
}
|
||||
return s.repo.CreateSystemConfig(config)
|
||||
}
|
||||
|
||||
func (s *systemService) UpdateSystemConfig(id string, config *model.SystemConfig) error {
|
||||
return s.repo.UpdateSystemConfig(id, config)
|
||||
}
|
||||
|
||||
func (s *systemService) InitDefaultMenus() error {
|
||||
menus, err := s.repo.GetAllMenus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(menus) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultMenus := []*model.Menu{
|
||||
{ID: "menu-user", Name: "人员管理", Icon: "fas fa-users", Path: "user", ParentID: "", SortOrder: 1, IsFixed: true, IsEnabled: true},
|
||||
{ID: "menu-menu", Name: "菜单管理", Icon: "fas fa-list", Path: "menu", ParentID: "", SortOrder: 2, IsFixed: true, IsEnabled: true},
|
||||
{ID: "menu-settings", Name: "系统设置", Icon: "fas fa-cog", Path: "settings", ParentID: "", SortOrder: 3, IsFixed: true, IsEnabled: true},
|
||||
}
|
||||
|
||||
for _, menu := range defaultMenus {
|
||||
if err := s.repo.CreateMenu(menu); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
CreateUser(user *model.User) error
|
||||
GetUserByUsername(username string) (*model.User, error)
|
||||
GetUserByID(id string) (*model.User, error)
|
||||
GetAllUsers() ([]*model.User, error)
|
||||
GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error)
|
||||
UpdateUser(id string, user *model.User) error
|
||||
DeleteUser(id string) error
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewUserService(repo repository.ResumeRepository) UserService {
|
||||
return &userService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *userService) CreateUser(user *model.User) error {
|
||||
if user.ID == "" {
|
||||
user.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateUser(user)
|
||||
}
|
||||
|
||||
func (s *userService) GetUserByUsername(username string) (*model.User, error) {
|
||||
return s.repo.GetUserByUsername(username)
|
||||
}
|
||||
|
||||
func (s *userService) GetUserByID(id string) (*model.User, error) {
|
||||
return s.repo.GetUserByID(id)
|
||||
}
|
||||
|
||||
func (s *userService) GetAllUsers() ([]*model.User, error) {
|
||||
return s.repo.GetAllUsers()
|
||||
}
|
||||
|
||||
func (s *userService) GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error) {
|
||||
return s.repo.GetUsersWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *userService) UpdateUser(id string, user *model.User) error {
|
||||
return s.repo.UpdateUser(id, user)
|
||||
}
|
||||
|
||||
func (s *userService) DeleteUser(id string) error {
|
||||
return s.repo.DeleteUser(id)
|
||||
}
|
||||
Reference in New Issue
Block a user