首次提交:初始化项目代码

This commit is contained in:
sunct
2026-07-31 14:44:48 +08:00
commit a4fe393571
119 changed files with 29105 additions and 0 deletions
+239
View File
@@ -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
}
+827
View File
@@ -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+ACK3.客户端发送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",
"**解析:** remroot 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- PProcessor)负责调度goroutine\n- GGoroutine)等待被调度执行\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- GetO(1)\n- PutO(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
}
+113
View File
@@ -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"
}
+113
View File
@@ -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"
}
+76
View File
@@ -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
}
}
+213
View File
@@ -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"
}
}
+113
View File
@@ -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"
}
+87
View File
@@ -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)
}
+205
View File
@@ -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
+594
View File
@@ -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 "内容"
}
}
+71
View File
@@ -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)
}
+124
View File
@@ -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"})
}
+163
View File
@@ -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
})
}
+168
View File
@@ -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})
}
+154
View File
@@ -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
+51
View File
@@ -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")
}
}
}
+25
View File
@@ -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()
}
}
+31
View File
@@ -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"
)
+95
View File
@@ -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"` // 网站LogoBase64
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"` // 创建时间
}
+30
View File
@@ -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"` // 向量总数
}
+170
View File
@@ -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"` // 添加时间
}
+427
View File
@@ -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)
}
+464
View File
@@ -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
}
+79
View File
@@ -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
+108
View File
@@ -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)
}
}
+31
View File
@@ -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)
}
}
+21
View File
@@ -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)
}
}
}
+12
View File
@@ -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)
}
}
+14
View File
@@ -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)
}
}
+12
View File
@@ -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)
}
}
+18
View File
@@ -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)
}
}
+15
View File
@@ -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)
}
}
+19
View File
@@ -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)
}
}
+17
View File
@@ -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)
}
}
+20
View File
@@ -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)
}
}
+17
View File
@@ -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)
}
}
+23
View File
@@ -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)
})
}
+199
View File
@@ -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])
}
+62
View File
@@ -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)
}
+93
View File
@@ -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)
}
+51
View File
@@ -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
}
+295
View File
@@ -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
}
}
+77
View File
@@ -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
}
+34
View File
@@ -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
}
+46
View File
@@ -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)
}
+65
View File
@@ -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)
}
+257
View File
@@ -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[内容已截断,仅保留关键信息]"
}
+222
View File
@@ -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
}
+85
View File
@@ -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
}
+57
View File
@@ -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)
}