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

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
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