This commit is contained in:
2026-01-12 11:33:43 +08:00
commit f07062dbd7
38 changed files with 6805 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
package handler
import (
"encoding/json"
"fmt"
"log"
"net/http"
"qd-sc/internal/model"
"qd-sc/internal/service"
"time"
"github.com/gin-gonic/gin"
)
// 使用服务层定义的固定模型名称
// ChatHandler 聊天处理器
type ChatHandler struct {
chatService *service.ChatService
response *Response
}
// NewChatHandler 创建聊天处理器
func NewChatHandler(chatService *service.ChatService) *ChatHandler {
return &ChatHandler{
chatService: chatService,
response: DefaultResponse,
}
}
// ChatCompletions 处理聊天completions请求
func (h *ChatHandler) ChatCompletions(c *gin.Context) {
var req model.ChatCompletionRequest
// 只支持 JSON 请求(文件通过 image_url 字段以 URL 方式传递)
if err := c.ShouldBindJSON(&req); err != nil {
h.response.Error(c, http.StatusBadRequest, "invalid_request", "无效的请求格式: "+err.Error())
return
}
// 验证请求
if req.Model == "" {
h.response.Error(c, http.StatusBadRequest, "invalid_request", "缺少model参数")
return
}
// 验证模型名称(只接受固定模型名)
if req.Model != service.ExposedModelName {
h.response.Error(c, http.StatusBadRequest, "invalid_request", fmt.Sprintf("不支持的模型,请使用: %s", service.ExposedModelName))
return
}
if len(req.Messages) == 0 {
h.response.Error(c, http.StatusBadRequest, "invalid_request", "messages不能为空")
return
}
// 根据stream参数决定返回方式
if req.Stream {
h.handleStreamResponse(c, &req)
} else {
h.handleNonStreamResponse(c, &req)
}
}
// handleNonStreamResponse 处理非流式响应
func (h *ChatHandler) handleNonStreamResponse(c *gin.Context, req *model.ChatCompletionRequest) {
resp, err := h.chatService.ProcessChatRequest(req)
if err != nil {
log.Printf("处理聊天请求失败: %v", err)
h.response.Error(c, http.StatusInternalServerError, "internal_error", "处理请求失败: "+err.Error())
return
}
h.response.Success(c, resp)
}
// handleStreamResponse 处理流式响应
func (h *ChatHandler) handleStreamResponse(c *gin.Context, req *model.ChatCompletionRequest) {
// 设置SSE响应头
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Transfer-Encoding", "chunked")
// 传递context以支持取消
ctx := c.Request.Context()
chunkChan, errChan := h.chatService.ProcessChatRequestStream(ctx, req)
// 持续发送流式数据
for {
select {
case chunk, ok := <-chunkChan:
if !ok {
// 通道已关闭,发送[DONE]标记OpenAI标准格式
if _, err := fmt.Fprintf(c.Writer, "data: [DONE]\n\n"); err != nil {
log.Printf("写入[DONE]标记失败: %v", err)
}
c.Writer.Flush()
log.Printf("SSE流已结束已发送[DONE]标记")
return
}
// 发送chunk
chunkJSON, err := json.Marshal(chunk)
if err != nil {
log.Printf("序列化chunk失败: %v", err)
continue
}
// 写入SSE格式
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunkJSON)); err != nil {
log.Printf("写入SSE数据失败: %v", err)
return
}
c.Writer.Flush()
// 如果这个chunk包含finish_reason记录日志
if len(chunk.Choices) > 0 && chunk.Choices[0].FinishReason != "" {
log.Printf("已发送finish_reason=%s的chunk", chunk.Choices[0].FinishReason)
}
case err, ok := <-errChan:
if ok && err != nil {
log.Printf("流式处理错误: %v", err)
// 发送错误信息
errChunk := model.ChatCompletionChunk{
ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: service.ExposedModelName,
Choices: []model.ChunkChoice{
{
Index: 0,
Delta: model.Message{
Role: "assistant",
Content: fmt.Sprintf("\n\n错误%s", err.Error()),
},
FinishReason: "error",
},
},
}
chunkJSON, _ := json.Marshal(errChunk)
fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunkJSON))
c.Writer.Flush()
// 发送DONE
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
c.Writer.Flush()
return
}
case <-c.Request.Context().Done():
// 客户端断开连接
log.Printf("客户端断开连接")
return
}
}
}

View File

@@ -0,0 +1,23 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// HealthHandler 健康检查处理器
type HealthHandler struct{}
// NewHealthHandler 创建健康检查处理器
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
// Check 健康检查
func (h *HealthHandler) Check(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "qd-sc-server",
})
}

View File

@@ -0,0 +1,26 @@
package handler
import (
"net/http"
"qd-sc/pkg/metrics"
"github.com/gin-gonic/gin"
)
// MetricsHandler 指标处理器
type MetricsHandler struct {
metrics *metrics.Metrics
}
// NewMetricsHandler 创建指标处理器
func NewMetricsHandler() *MetricsHandler {
return &MetricsHandler{
metrics: metrics.GetGlobalMetrics(),
}
}
// GetMetrics 获取性能指标
func (h *MetricsHandler) GetMetrics(c *gin.Context) {
stats := h.metrics.GetStats()
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"qd-sc/internal/model"
"github.com/gin-gonic/gin"
)
// Response 统一响应处理器
type Response struct{}
// Error 发送错误响应
func (r *Response) Error(c *gin.Context, statusCode int, errorType, message string) {
c.JSON(statusCode, model.ErrorResponse{
Error: model.ErrorDetail{
Message: message,
Type: errorType,
},
})
}
// Success 发送成功响应
func (r *Response) Success(c *gin.Context, data interface{}) {
c.JSON(200, data)
}
// NewResponse 创建响应处理器
func NewResponse() *Response {
return &Response{}
}
// 全局响应处理器实例
var DefaultResponse = NewResponse()

View File

@@ -0,0 +1,33 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
// 当需要携带凭证Cookie/Authorization规范要求不能使用 "*"
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
} else {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
c.Writer.Header().Set("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestCORS_WithOrigin_EchoOriginAndCredentials(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CORS())
r.GET("/ping", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("Origin", "https://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Fatalf("expected allow-origin to echo origin, got %q", got)
}
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("expected allow-credentials true, got %q", got)
}
if got := w.Header().Get("Vary"); got != "Origin" {
t.Fatalf("expected Vary Origin, got %q", got)
}
}
func TestCORS_NoOrigin_AllowAnyOriginWithoutCredentials(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CORS())
r.GET("/ping", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("expected allow-origin '*', got %q", got)
}
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("expected allow-credentials empty, got %q", got)
}
}

View File

@@ -0,0 +1,41 @@
package middleware
import (
"qd-sc/pkg/metrics"
"time"
"github.com/gin-gonic/gin"
)
// Metrics 指标收集中间件
func Metrics() gin.HandlerFunc {
m := metrics.GetGlobalMetrics()
return func(c *gin.Context) {
// 记录请求开始时间
start := time.Now()
// 增加总请求数和活跃请求数
m.IncTotalRequests()
m.IncActiveRequests()
defer m.DecActiveRequests()
// 检查是否是流式请求
if c.GetHeader("Accept") == "text/event-stream" || c.Query("stream") == "true" {
m.IncStreamRequests()
}
// 处理请求
c.Next()
// 记录延迟
duration := time.Since(start)
endpoint := c.Request.Method + " " + c.FullPath()
m.RecordLatency(endpoint, duration)
// 如果请求失败,增加失败计数
if c.Writer.Status() >= 400 {
m.IncFailedRequests()
}
}
}

View File

@@ -0,0 +1,103 @@
package middleware
import (
"net/http"
"qd-sc/internal/model"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
)
// RateLimiter 基于令牌桶的限流器(使用原子操作)
type RateLimiter struct {
tokens int64 // 当前令牌数
capacity int64 // 桶容量(最大突发请求数)
refillRate int64 // 每秒补充的令牌数持续QPS
lastRefill int64 // 上次补充时间(纳秒时间戳)
now func() int64 // 便于测试注入UnixNano
}
// NewRateLimiter 创建限流器
// capacity: 桶容量(最大突发请求数)
// refillRate: 每秒补充的令牌数持续QPS
func NewRateLimiter(capacity, refillRate int) *RateLimiter {
return &RateLimiter{
tokens: int64(capacity),
capacity: int64(capacity),
refillRate: int64(refillRate),
lastRefill: time.Now().UnixNano(),
now: func() int64 {
return time.Now().UnixNano()
},
}
}
// Allow 尝试消耗一个令牌使用CAS无锁算法
func (rl *RateLimiter) Allow() bool {
now := rl.now()
for {
// 读取当前状态
currentTokens := atomic.LoadInt64(&rl.tokens)
lastRefill := atomic.LoadInt64(&rl.lastRefill)
// 计算应该补充的令牌
elapsed := now - lastRefill
if elapsed < 0 {
// 时钟回拨等极端情况:不补充
elapsed = 0
}
// 安全计算:避免 elapsed * refillRate 直接相乘造成溢出
// tokensToAdd = floor(elapsed_ns * refillRate_per_sec / 1e9)
secPart := elapsed / int64(time.Second) // elapsed 秒
nsecPart := elapsed % int64(time.Second) // 剩余纳秒
tokensToAdd := secPart*rl.refillRate + (nsecPart*rl.refillRate)/int64(time.Second)
newTokens := currentTokens
if tokensToAdd > 0 {
newTokens = currentTokens + tokensToAdd
if newTokens > rl.capacity {
newTokens = rl.capacity
}
}
// 检查是否有令牌可用
if newTokens < 1 {
return false
}
// 尝试消耗一个令牌
if atomic.CompareAndSwapInt64(&rl.tokens, currentTokens, newTokens-1) {
// 更新最后补充时间
if tokensToAdd > 0 {
atomic.StoreInt64(&rl.lastRefill, now)
}
return true
}
// CAS失败重试
}
}
// RateLimit 限流中间件
func RateLimit(capacity, refillRate int) gin.HandlerFunc {
limiter := NewRateLimiter(capacity, refillRate)
return func(c *gin.Context) {
if !limiter.Allow() {
c.JSON(http.StatusTooManyRequests, model.ErrorResponse{
Error: model.ErrorDetail{
Message: "请求过于频繁,请稍后再试",
Type: "rate_limit_exceeded",
},
})
c.Abort()
return
}
c.Next()
}
}

View File

@@ -0,0 +1,35 @@
package middleware
import (
"testing"
"time"
)
func TestRateLimiter_AllowAndRefill(t *testing.T) {
rl := NewRateLimiter(2, 1) // 容量2每秒补充1
// 注入可控时间
now := time.Unix(0, 0).UnixNano()
rl.now = func() int64 { return now }
rl.lastRefill = now
rl.tokens = 2
if !rl.Allow() {
t.Fatalf("expected first Allow() true")
}
if !rl.Allow() {
t.Fatalf("expected second Allow() true")
}
if rl.Allow() {
t.Fatalf("expected third Allow() false (no tokens)")
}
// 过 1 秒应补充 1 个令牌
now += int64(time.Second)
if !rl.Allow() {
t.Fatalf("expected Allow() true after refill")
}
if rl.Allow() {
t.Fatalf("expected Allow() false again (tokens should be 0)")
}
}

View File

@@ -0,0 +1,35 @@
package middleware
import (
"log"
"net/http"
"qd-sc/internal/model"
"runtime/debug"
"github.com/gin-gonic/gin"
)
// Recovery Panic恢复中间件
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// 打印堆栈信息
stack := debug.Stack()
log.Printf("[PANIC] %v\n%s", err, string(stack))
// 返回500错误
c.JSON(http.StatusInternalServerError, model.ErrorResponse{
Error: model.ErrorDetail{
Message: "服务器内部错误",
Type: "internal_server_error",
},
})
c.Abort()
}
}()
c.Next()
}
}

View File

@@ -0,0 +1,90 @@
package client
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"qd-sc/internal/config"
"qd-sc/internal/model"
"strings"
)
// AmapClient 高德地图客户端
type AmapClient struct {
baseURL string
apiKey string
cityName string
httpClient *http.Client
}
// NewAmapClient 创建高德地图客户端
func NewAmapClient(cfg *config.Config) *AmapClient {
return &AmapClient{
baseURL: cfg.Amap.BaseURL,
apiKey: cfg.Amap.APIKey,
cityName: cfg.City.Name,
httpClient: NewHTTPClient(HTTPClientConfig{Timeout: cfg.Amap.Timeout, MaxIdleConns: 100, MaxIdleConnsPerHost: 50, MaxConnsPerHost: 0}),
}
}
// SearchPlace 搜索地点,返回经纬度
func (c *AmapClient) SearchPlace(keywords string) (*model.AmapPlaceResponse, error) {
// 构建请求URL
params := url.Values{}
params.Set("key", c.apiKey)
params.Set("keywords", keywords)
params.Set("types", "190000") // 地名地址信息类型
params.Set("city", c.cityName)
params.Set("output", "JSON")
reqURL := fmt.Sprintf("%s/place/text?%s", c.baseURL, params.Encode())
resp, err := c.httpClient.Get(reqURL)
if err != nil {
return nil, fmt.Errorf("HTTP请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
var result model.AmapPlaceResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if result.Status != "1" {
return nil, fmt.Errorf("高德API返回错误: %s", result.Info)
}
return &result, nil
}
// GetLocationCoordinates 获取地点的经纬度坐标
func (c *AmapClient) GetLocationCoordinates(keywords string) (latitude, longitude string, err error) {
result, err := c.SearchPlace(keywords)
if err != nil {
return "", "", err
}
if len(result.Pois) == 0 {
return "", "", fmt.Errorf("未找到地点: %s", keywords)
}
// 取第一个结果
location := result.Pois[0].Location
parts := strings.Split(location, ",")
if len(parts) != 2 {
return "", "", fmt.Errorf("解析坐标失败: %s", location)
}
// 高德返回的格式是"经度,纬度"
longitude = parts[0]
latitude = parts[1]
return latitude, longitude, nil
}

View File

@@ -0,0 +1,63 @@
package client
import (
"net/http"
"time"
)
// HTTPClientConfig HTTP客户端配置
type HTTPClientConfig struct {
Timeout time.Duration
MaxIdleConns int
MaxIdleConnsPerHost int
MaxConnsPerHost int
}
// DefaultHTTPClientConfig 默认HTTP客户端配置
var DefaultHTTPClientConfig = HTTPClientConfig{
Timeout: 30 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 50,
MaxConnsPerHost: 0, // 0表示不限制
}
// NewHTTPClient 创建优化的HTTP客户端
func NewHTTPClient(config HTTPClientConfig) *http.Client {
transport := &http.Transport{
// 连接池配置
MaxIdleConns: config.MaxIdleConns,
MaxIdleConnsPerHost: config.MaxIdleConnsPerHost,
MaxConnsPerHost: config.MaxConnsPerHost,
IdleConnTimeout: 90 * time.Second,
// 性能优化
DisableCompression: false,
DisableKeepAlives: false,
ForceAttemptHTTP2: true, // 启用HTTP/2
// 超时配置
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// 缓冲大小
WriteBufferSize: 32 * 1024, // 32KB写缓冲
ReadBufferSize: 32 * 1024, // 32KB读缓冲
}
return &http.Client{
Timeout: config.Timeout,
Transport: transport,
}
}
// NewLLMHTTPClient 创建用于LLM的HTTP客户端连接池更大
func NewLLMHTTPClient(timeout time.Duration) *http.Client {
config := HTTPClientConfig{
Timeout: timeout,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100,
MaxConnsPerHost: 0,
}
return NewHTTPClient(config)
}

View File

@@ -0,0 +1,176 @@
package client
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"qd-sc/internal/config"
"qd-sc/internal/model"
"strconv"
)
// JobClient 岗位API客户端
type JobClient struct {
baseURL string
httpClient *http.Client
logLevel string
locationMap map[string]string // 区域代码到名称的映射
}
// NewJobClient 创建岗位API客户端
func NewJobClient(cfg *config.Config) *JobClient {
// 构建区域代码到名称的映射(反转配置中的映射)
locationMap := make(map[string]string)
for name, code := range cfg.City.AreaCodes {
locationMap[code] = name
}
return &JobClient{
baseURL: cfg.JobAPI.BaseURL,
httpClient: NewHTTPClient(HTTPClientConfig{Timeout: cfg.JobAPI.Timeout, MaxIdleConns: 100, MaxIdleConnsPerHost: 50, MaxConnsPerHost: 0}),
logLevel: cfg.Logging.Level,
locationMap: locationMap,
}
}
// QueryJobs 查询岗位
func (c *JobClient) QueryJobs(req *model.JobQueryRequest) (*model.JobAPIResponse, error) {
// 构建请求URL
params := url.Values{}
params.Set("current", strconv.Itoa(req.Current))
params.Set("pageSize", strconv.Itoa(req.PageSize))
if req.JobTitle != "" {
params.Set("jobTitle", req.JobTitle)
}
if req.Latitude != "" {
params.Set("latitude", req.Latitude)
}
if req.Longitude != "" {
params.Set("longitude", req.Longitude)
}
if req.Radius != "" {
params.Set("radius", req.Radius)
}
if req.Order != "" {
params.Set("order", req.Order)
}
if req.MinSalary != "" {
params.Set("minSalary", req.MinSalary)
}
if req.MaxSalary != "" {
params.Set("maxSalary", req.MaxSalary)
}
if req.Experience != "" {
params.Set("experience", req.Experience)
}
if req.Education != "" {
params.Set("education", req.Education)
}
if req.CompanyNature != "" {
params.Set("companyNature", req.CompanyNature)
}
if req.JobLocationAreaCode != "" {
params.Set("jobLocationAreaCode", req.JobLocationAreaCode)
}
reqURL := fmt.Sprintf("%s/job/aiList?%s", c.baseURL, params.Encode())
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
// 读取响应体用于日志和解析
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 打印原始响应仅在debug级别时显示完整响应否则显示摘要
if c.logLevel == "debug" {
log.Printf("岗位API原始响应: %s", string(body))
} else {
// 非debug模式下显示响应摘要
if len(body) > 200 {
log.Printf("岗位API响应摘要: %s... (共%d字节)", string(body[:200]), len(body))
} else {
log.Printf("岗位API响应: %s", string(body))
}
}
var result model.JobAPIResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w, 原始响应: %s", err, string(body))
}
log.Printf("岗位API解析结果: Code=%d, Msg=%s, Rows数量=%d", result.Code, result.Msg, len(result.Rows))
return &result, nil
}
// FormatJobResponse 格式化岗位响应
func (c *JobClient) FormatJobResponse(apiResp *model.JobAPIResponse) *model.JobResponse {
formattedJobs := make([]model.FormattedJob, 0, len(apiResp.Rows))
for _, job := range apiResp.Rows {
// 格式化薪资
salary := "薪资面议"
if job.MinSalary > 0 || job.MaxSalary > 0 {
salary = fmt.Sprintf("%d-%d元/月", job.MinSalary, job.MaxSalary)
}
// 转换学历代码
education := model.EducationMap[job.Education]
if education == "" {
education = "学历不限"
}
// 转换经验代码
experience := model.ExperienceMap[job.Experience]
if experience == "" {
experience = "经验不限"
}
// 转换区域代码
location := c.locationMap[strconv.Itoa(job.JobLocationAreaCode)]
if location == "" {
location = "未知地区"
}
formattedJobs = append(formattedJobs, model.FormattedJob{
JobTitle: job.JobTitle,
CompanyName: job.CompanyName,
Salary: salary,
Location: location,
Education: education,
Experience: experience,
AppJobURL: job.AppJobURL,
})
}
// 如果有data字段在最后一条job中添加
if len(formattedJobs) > 0 && apiResp.Data != nil {
formattedJobs[len(formattedJobs)-1].Data = apiResp.Data
}
return &model.JobResponse{
JobListings: formattedJobs,
Data: apiResp.Data,
}
}

View File

@@ -0,0 +1,156 @@
package client
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"qd-sc/internal/config"
"qd-sc/internal/model"
"strings"
"time"
)
// LLMClient LLM客户端
type LLMClient struct {
baseURL string
apiKey string
httpClient *http.Client
maxRetries int
}
// NewLLMClient 创建LLM客户端
func NewLLMClient(cfg *config.Config) *LLMClient {
return &LLMClient{
baseURL: cfg.LLM.BaseURL,
apiKey: cfg.LLM.APIKey,
httpClient: NewLLMHTTPClient(cfg.LLM.Timeout),
maxRetries: cfg.LLM.MaxRetries,
}
}
// ChatCompletion 发起聊天补全请求(非流式)
func (c *LLMClient) ChatCompletion(req *model.ChatCompletionRequest) (*model.ChatCompletionResponse, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
var resp *http.Response
var lastErr error
// 重试机制
for i := 0; i < c.maxRetries; i++ {
// 注意http.Request 的 Body 在 Do() 后会被读取消耗,重试必须重建 request/body
httpReq, err := http.NewRequest("POST", c.baseURL+"/chat/completions", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, lastErr = c.httpClient.Do(httpReq)
// 请求未发出/网络错误:可重试
if lastErr != nil {
time.Sleep(time.Duration(i+1) * time.Second)
continue
}
// 根据状态码决定是否重试5xx 和 429 重试;其他 4xx 直接返回
if resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests {
break
}
resp.Body.Close()
time.Sleep(time.Duration(i+1) * time.Second)
}
if lastErr != nil {
return nil, fmt.Errorf("HTTP请求失败: %w", lastErr)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
var result model.ChatCompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
return &result, nil
}
// ChatCompletionStream 发起聊天补全请求(流式)
func (c *LLMClient) ChatCompletionStream(req *model.ChatCompletionRequest) (chan *model.ChatCompletionChunk, chan error, error) {
req.Stream = true
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("序列化请求失败: %w", err)
}
httpReq, err := http.NewRequest("POST", c.baseURL+"/chat/completions", bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("创建HTTP请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Accept", "text/event-stream")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("HTTP请求失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
chunkChan := make(chan *model.ChatCompletionChunk, 100)
errChan := make(chan error, 1)
go func() {
defer resp.Body.Close()
defer close(chunkChan)
defer close(errChan)
scanner := bufio.NewScanner(resp.Body)
// 设置最大buffer大小防止被超大响应攻击默认64KB增加到1MB
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
return
}
var chunk model.ChatCompletionChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
errChan <- fmt.Errorf("解析流式响应失败: %w", err)
return
}
chunkChan <- &chunk
}
if err := scanner.Err(); err != nil {
errChan <- fmt.Errorf("读取流失败: %w", err)
}
}()
return chunkChan, errChan, nil
}

View File

@@ -0,0 +1,75 @@
package client
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"qd-sc/internal/model"
)
func TestLLMClient_ChatCompletion_RetryRebuildsRequestBody(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
w.WriteHeader(http.StatusNotFound)
return
}
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if len(body) == 0 {
t.Fatalf("expected non-empty request body")
}
n := atomic.AddInt32(&calls, 1)
if n == 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
// 第二次返回一个最小合法响应
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(model.ChatCompletionResponse{
ID: "chatcmpl-test",
Object: "chat.completion",
Created: time.Now().Unix(),
Model: "test-model",
Choices: []model.Choice{{Index: 0, FinishReason: "stop"}},
})
}))
defer srv.Close()
c := &LLMClient{
baseURL: srv.URL,
apiKey: "test",
httpClient: srv.Client(),
maxRetries: 2,
}
_, err := c.ChatCompletion(&model.ChatCompletionRequest{
Model: "test-model",
Messages: []model.Message{
{Role: "user", Content: "hi"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("expected 2 calls due to retry, got %d", got)
}
}

View File

@@ -0,0 +1,113 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"qd-sc/internal/config"
)
// OCRClient OCR服务客户端
type OCRClient struct {
baseURL string
httpClient *http.Client
logLevel string
}
// OCRResponse OCR服务响应结构
type OCRResponse struct {
Code int `json:"code"`
Data string `json:"data"`
CostTimeMs float64 `json:"cost_time_ms"`
Msg string `json:"msg,omitempty"`
}
// NewOCRClient 创建OCR客户端
func NewOCRClient(cfg *config.Config) *OCRClient {
return &OCRClient{
baseURL: cfg.OCR.BaseURL,
httpClient: NewHTTPClient(HTTPClientConfig{Timeout: cfg.OCR.Timeout, MaxIdleConns: 100, MaxIdleConnsPerHost: 50, MaxConnsPerHost: 0}),
logLevel: cfg.Logging.Level,
}
}
// ParseURL 通过URL解析远程文件内容
// 支持图片、PDF、Excel、PPT等格式
func (c *OCRClient) ParseURL(fileURL string) (string, error) {
// 构建请求体
reqBody := map[string]string{
"url": fileURL,
}
reqData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
// 打印文件解析请求体
log.Printf("OCR文件解析请求: URL=%s, 请求体=%s", c.baseURL+"/ocr/url", string(reqData))
// 创建HTTP请求
httpReq, err := http.NewRequest("POST", c.baseURL+"/ocr/url", bytes.NewReader(reqData))
if err != nil {
return "", fmt.Errorf("创建HTTP请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// 发送请求
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("HTTP请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %w", err)
}
// 打印原始响应仅在debug级别时显示完整响应否则显示摘要
if c.logLevel == "debug" {
log.Printf("OCR原始响应: %s", string(respBody))
} else {
// 非debug模式下显示响应摘要
if len(respBody) > 200 {
log.Printf("OCR响应摘要: %s... (共%d字节)", string(respBody[:200]), len(respBody))
} else {
log.Printf("OCR响应: %s", string(respBody))
}
}
// 解析响应
var ocrResp OCRResponse
if err := json.Unmarshal(respBody, &ocrResp); err != nil {
return "", fmt.Errorf("解析响应失败: %w", err)
}
// 打印解析结果
log.Printf("OCR解析结果: Code=%d, CostTimeMs=%.2f, 数据长度=%d字节", ocrResp.Code, ocrResp.CostTimeMs, len(ocrResp.Data))
if c.logLevel == "debug" && ocrResp.Data != "" {
// debug模式下打印解析出的数据内容
if len(ocrResp.Data) > 500 {
log.Printf("OCR解析数据(前500字符): %s...", ocrResp.Data[:500])
} else {
log.Printf("OCR解析数据: %s", ocrResp.Data)
}
}
// 检查业务状态码
if ocrResp.Code != 200 {
errMsg := ocrResp.Msg
if errMsg == "" {
errMsg = "OCR解析失败"
}
return "", fmt.Errorf("OCR服务返回错误: %s", errMsg)
}
return ocrResp.Data, nil
}

View File

@@ -0,0 +1,256 @@
package client
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"qd-sc/internal/config"
"qd-sc/internal/model"
"strings"
"sync"
"time"
)
// PolicyClient 政策大模型客户端
type PolicyClient struct {
baseURL string
loginName string
userKey string
serviceID string
httpClient *http.Client
// ticket管理
mu sync.RWMutex
currentTicket *model.PolicyTicketData
ticketExpiresAt time.Time
}
// NewPolicyClient 创建政策大模型客户端
func NewPolicyClient(cfg *config.Config) *PolicyClient {
return &PolicyClient{
baseURL: cfg.Policy.BaseURL,
loginName: cfg.Policy.LoginName,
userKey: cfg.Policy.UserKey,
serviceID: cfg.Policy.ServiceID,
httpClient: NewHTTPClient(HTTPClientConfig{
Timeout: cfg.Policy.Timeout,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 50,
MaxConnsPerHost: 0,
}),
}
}
// GetTicket 获取ticket会自动缓存和刷新
func (c *PolicyClient) GetTicket() (*model.PolicyTicketData, error) {
c.mu.RLock()
// 如果ticket存在且未过期提前5分钟刷新
if c.currentTicket != nil && time.Now().Before(c.ticketExpiresAt.Add(-5*time.Minute)) {
ticket := c.currentTicket
c.mu.RUnlock()
return ticket, nil
}
c.mu.RUnlock()
// 需要获取新ticket
c.mu.Lock()
defer c.mu.Unlock()
// 双重检查,防止并发重复请求
if c.currentTicket != nil && time.Now().Before(c.ticketExpiresAt.Add(-5*time.Minute)) {
return c.currentTicket, nil
}
// 发起请求获取ticket
req := &model.PolicyTicketRequest{
LoginName: c.loginName,
UserKey: c.userKey,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
url := fmt.Sprintf("%s/api/aiServer/getAccessUserInfo", c.baseURL)
httpReq, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
var result model.PolicyTicketResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if result.Code != 200 {
return nil, fmt.Errorf("获取ticket失败: %s", result.Message)
}
if result.Data == nil {
return nil, fmt.Errorf("返回数据为空")
}
// 缓存ticket设置过期时间为1小时后
c.currentTicket = result.Data
c.ticketExpiresAt = time.Now().Add(1 * time.Hour)
return c.currentTicket, nil
}
// Chat 发起政策咨询对话(非流式)
func (c *PolicyClient) Chat(chatReq *model.PolicyChatData) (*model.PolicyChatResponse, error) {
// 获取ticket
ticketData, err := c.GetTicket()
if err != nil {
return nil, fmt.Errorf("获取ticket失败: %w", err)
}
// 构造请求
req := &model.PolicyChatRequest{
AppID: ticketData.AppID,
Ticket: ticketData.Ticket,
Data: chatReq,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
url := fmt.Sprintf("%s/api/aiServer/aichat/stream-ai/%s", c.baseURL, c.serviceID)
httpReq, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
var result model.PolicyChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if result.Code != 200 {
return nil, fmt.Errorf("对话请求失败: %s", result.Message)
}
return &result, nil
}
// ChatStream 发起政策咨询对话(流式)
func (c *PolicyClient) ChatStream(chatReq *model.PolicyChatData) (chan string, chan error, error) {
// 获取ticket
ticketData, err := c.GetTicket()
if err != nil {
return nil, nil, fmt.Errorf("获取ticket失败: %w", err)
}
// 设置流式模式
chatReq.Stream = true
// 构造请求
req := &model.PolicyChatRequest{
AppID: ticketData.AppID,
Ticket: ticketData.Ticket,
Data: chatReq,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("序列化请求失败: %w", err)
}
url := fmt.Sprintf("%s/api/aiServer/aichat/stream-ai/%s", c.baseURL, c.serviceID)
httpReq, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("创建HTTP请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("HTTP请求失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
contentChan := make(chan string, 100)
errChan := make(chan error, 1)
go func() {
defer resp.Body.Close()
defer close(contentChan)
defer close(errChan)
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
// 跳过空行
if strings.TrimSpace(line) == "" {
continue
}
// 尝试解析为JSON响应
var chunkResp model.PolicyChatResponse
if err := json.Unmarshal([]byte(line), &chunkResp); err != nil {
// 如果不是JSON格式可能是纯文本流直接发送
contentChan <- line
continue
}
// 检查响应码
if chunkResp.Code != 200 {
errChan <- fmt.Errorf("对话请求失败: %s", chunkResp.Message)
return
}
// 发送消息内容
if chunkResp.Data != nil && chunkResp.Data.Message != "" {
contentChan <- chunkResp.Data.Message
}
}
if err := scanner.Err(); err != nil {
errChan <- fmt.Errorf("读取流失败: %w", err)
}
}()
return contentChan, errChan, nil
}

252
internal/config/config.go Normal file
View File

@@ -0,0 +1,252 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Config 应用配置
type Config struct {
Server ServerConfig `yaml:"server"`
City CityConfig `yaml:"city"`
LLM LLMConfig `yaml:"llm"`
Amap AmapConfig `yaml:"amap"`
JobAPI JobAPIConfig `yaml:"job_api"`
OCR OCRConfig `yaml:"ocr"`
Policy PolicyConfig `yaml:"policy"`
Logging LoggingConfig `yaml:"logging"`
Performance PerformanceConfig `yaml:"performance"`
}
// CityConfig 城市配置
type CityConfig struct {
Name string `yaml:"name"` // 城市名称,如:青岛
SystemName string `yaml:"system_name"` // 系统名称,如:青岛岗位匹配系统
AreaCodes map[string]string `yaml:"area_codes"` // 区域代码映射,如:市南区:0, 市北区:1
Landmarks []string `yaml:"landmarks"` // 地标示例,如:五四广场、青岛啤酒博物馆
Abbreviations map[string]string `yaml:"abbreviations"` // 简称映射,如:青啤:青岛啤酒
}
// ServerConfig 服务器配置
type ServerConfig struct {
Port int `yaml:"port"`
Host string `yaml:"host"`
ReadTimeout time.Duration `yaml:"read_timeout"`
WriteTimeout time.Duration `yaml:"write_timeout"`
}
// LLMConfig LLM配置
type LLMConfig struct {
BaseURL string `yaml:"base_url"`
APIKey string `yaml:"api_key"`
Model string `yaml:"model"`
Timeout time.Duration `yaml:"timeout"`
MaxRetries int `yaml:"max_retries"`
}
// AmapConfig 高德地图配置
type AmapConfig struct {
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url"`
Timeout time.Duration `yaml:"timeout"`
}
// JobAPIConfig 岗位API配置
type JobAPIConfig struct {
BaseURL string `yaml:"base_url"`
Timeout time.Duration `yaml:"timeout"`
}
// OCRConfig OCR服务配置
type OCRConfig struct {
BaseURL string `yaml:"base_url"`
Timeout time.Duration `yaml:"timeout"`
}
// PolicyConfig 政策大模型配置
type PolicyConfig struct {
BaseURL string `yaml:"base_url"`
LoginName string `yaml:"login_name"`
UserKey string `yaml:"user_key"`
ServiceID string `yaml:"service_id"`
Timeout time.Duration `yaml:"timeout"`
}
// LoggingConfig 日志配置
type LoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
// PerformanceConfig 性能配置
type PerformanceConfig struct {
MaxGoroutines int `yaml:"max_goroutines"`
GoroutinePoolSize int `yaml:"goroutine_pool_size"`
TaskQueueSize int `yaml:"task_queue_size"`
EnablePprof *bool `yaml:"enable_pprof"`
EnableMetrics *bool `yaml:"enable_metrics"`
GCPercent int `yaml:"gc_percent"`
}
var globalConfig *Config
// Load 从文件加载配置
func Load(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
// 环境变量覆盖(用于生产环境,避免密钥泄露)
if v := os.Getenv("LLM_API_KEY"); v != "" {
cfg.LLM.APIKey = v
}
if v := os.Getenv("LLM_BASE_URL"); v != "" {
cfg.LLM.BaseURL = v
}
if v := os.Getenv("AMAP_API_KEY"); v != "" {
cfg.Amap.APIKey = v
}
if v := os.Getenv("OCR_BASE_URL"); v != "" {
cfg.OCR.BaseURL = v
}
if v := os.Getenv("POLICY_LOGIN_NAME"); v != "" {
cfg.Policy.LoginName = v
}
if v := os.Getenv("POLICY_USER_KEY"); v != "" {
cfg.Policy.UserKey = v
}
if v := os.Getenv("POLICY_SERVICE_ID"); v != "" {
cfg.Policy.ServiceID = v
}
if v := os.Getenv("SERVER_PORT"); v != "" {
fmt.Sscanf(v, "%d", &cfg.Server.Port)
}
// 设置默认值
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.LLM.MaxRetries == 0 {
cfg.LLM.MaxRetries = 3
}
if cfg.Server.ReadTimeout == 0 {
cfg.Server.ReadTimeout = 30 * time.Second
}
if cfg.Server.WriteTimeout == 0 {
cfg.Server.WriteTimeout = 300 * time.Second
}
// 城市配置默认值
if cfg.City.Name == "" {
cfg.City.Name = "青岛"
}
if cfg.City.SystemName == "" {
cfg.City.SystemName = cfg.City.Name + "岗位匹配系统"
}
if len(cfg.City.AreaCodes) == 0 {
cfg.City.AreaCodes = map[string]string{
"市南区": "0", "市北区": "1", "李沧区": "2", "崂山区": "3", "黄岛区": "4",
"城阳区": "5", "即墨区": "6", "胶州市": "7", "平度市": "8", "莱西市": "9",
}
}
if len(cfg.City.Landmarks) == 0 {
cfg.City.Landmarks = []string{"五四广场", "青岛啤酒博物馆"}
}
if len(cfg.City.Abbreviations) == 0 {
cfg.City.Abbreviations = map[string]string{"青啤": "青岛啤酒"}
}
// 性能配置默认值
if cfg.Performance.MaxGoroutines == 0 {
cfg.Performance.MaxGoroutines = 10000
}
if cfg.Performance.GoroutinePoolSize == 0 {
cfg.Performance.GoroutinePoolSize = 5000
}
if cfg.Performance.TaskQueueSize == 0 {
cfg.Performance.TaskQueueSize = 10000
}
if cfg.Performance.GCPercent == 0 {
cfg.Performance.GCPercent = 100
}
// pprof和metrics默认启用允许在配置文件中显式关闭
if cfg.Performance.EnablePprof == nil {
v := true
cfg.Performance.EnablePprof = &v
}
if cfg.Performance.EnableMetrics == nil {
v := true
cfg.Performance.EnableMetrics = &v
}
globalConfig = &cfg
return &cfg, nil
}
// Get 获取全局配置
func Get() *Config {
return globalConfig
}
// GetAreaCodesDescription 获取区域代码描述字符串
func (c *CityConfig) GetAreaCodesDescription() string {
if len(c.AreaCodes) == 0 {
return ""
}
// 按代码排序输出
result := ""
for i := 0; i <= 9; i++ {
for name, code := range c.AreaCodes {
if code == fmt.Sprintf("%d", i) {
if result != "" {
result += ", "
}
result += fmt.Sprintf("%s(%s)", name, code)
break
}
}
}
return result
}
// GetLandmarksExample 获取地标示例字符串
func (c *CityConfig) GetLandmarksExample() string {
if len(c.Landmarks) == 0 {
return ""
}
result := ""
for i, landmark := range c.Landmarks {
if i > 0 {
result += "、"
}
result += landmark
}
return result
}
// GetAbbreviationsDescription 获取简称映射描述
func (c *CityConfig) GetAbbreviationsDescription() string {
if len(c.Abbreviations) == 0 {
return ""
}
result := ""
for abbr, full := range c.Abbreviations {
if result != "" {
result += "、"
}
result += fmt.Sprintf("\"%s\"指\"%s\"", abbr, full)
}
return result
}

107
internal/model/job.go Normal file
View File

@@ -0,0 +1,107 @@
package model
// JobQueryRequest 岗位查询请求
type JobQueryRequest struct {
Current int `json:"current" form:"current"` // 当前页码
PageSize int `json:"pageSize" form:"pageSize"` // 每页数量
JobTitle string `json:"jobTitle,omitempty" form:"jobTitle"` // 岗位名称
Latitude string `json:"latitude,omitempty" form:"latitude"` // 纬度
Longitude string `json:"longitude,omitempty" form:"longitude"` // 经度
Radius string `json:"radius,omitempty" form:"radius"` // 搜索半径km
Order string `json:"order,omitempty" form:"order"` // 排序方式: 0-推荐, 1-最热, 2-最新
MinSalary string `json:"minSalary,omitempty" form:"minSalary"` // 最低薪资
MaxSalary string `json:"maxSalary,omitempty" form:"maxSalary"` // 最高薪资
Experience string `json:"experience,omitempty" form:"experience"` // 经验要求代码
Education string `json:"education,omitempty" form:"education"` // 学历要求代码
CompanyNature string `json:"companyNature,omitempty" form:"companyNature"` // 企业类型代码
JobLocationAreaCode string `json:"jobLocationAreaCode,omitempty" form:"jobLocationAreaCode"` // 区域代码
}
// JobAPIResponse 岗位API响应
type JobAPIResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Rows []JobListing `json:"rows"`
Data interface{} `json:"data,omitempty"`
}
// JobListing 岗位信息
type JobListing struct {
JobTitle string `json:"jobTitle"` // 职位名称
CompanyName string `json:"companyName"` // 公司名称
MinSalary int `json:"minSalary"` // 最低薪资
MaxSalary int `json:"maxSalary"` // 最高薪资
Education string `json:"education"` // 学历要求代码
Experience string `json:"experience"` // 经验要求代码
AppJobURL string `json:"appJobUrl"` // 职位链接
JobLocationAreaCode int `json:"jobLocationAreaCode"` // 工作地点代码
}
// FormattedJob 格式化后的岗位信息
type FormattedJob struct {
JobTitle string `json:"jobTitle"` // 职位名称
CompanyName string `json:"companyName"` // 公司名称
Salary string `json:"salary"` // 薪资范围
Location string `json:"location"` // 工作地点
Education string `json:"education"` // 学历要求
Experience string `json:"experience"` // 经验要求
AppJobURL string `json:"appJobUrl"` // 职位链接
Data interface{} `json:"data,omitempty"` // 额外数据(最后一条时包含)
}
// JobResponse 岗位查询结果
type JobResponse struct {
JobListings []FormattedJob `json:"jobListings"`
Data interface{} `json:"data,omitempty"`
}
// 学历代码映射
var EducationMap = map[string]string{
"-1": "学历不限",
"0": "初中及以下",
"1": "中专/中技",
"2": "高中",
"3": "大专",
"4": "本科",
"5": "硕士",
"6": "博士",
"7": "MBA/EMBA",
"8": "留学-学士",
"9": "留学-硕士",
"10": "留学-博士",
}
// 经验代码映射
var ExperienceMap = map[string]string{
"0": "经验不限",
"1": "实习生",
"2": "应届毕业生",
"3": "1年以下",
"4": "1-3年",
"5": "3-5年",
"6": "5-10年",
"7": "10年以上",
}
// 企业类型代码映射
var CompanyNatureMap = map[string]string{
"1": "私营企业",
"2": "股份制企业",
"3": "国有企业",
"4": "外商及港澳台投资企业",
"5": "医院",
}
// AmapPlaceResponse 高德地图地点查询响应
type AmapPlaceResponse struct {
Status string `json:"status"`
Info string `json:"info"`
Pois []AmapPlace `json:"pois"`
}
// AmapPlace 地点信息
type AmapPlace struct {
Name string `json:"name"`
Location string `json:"location"` // "经度,纬度"
Address string `json:"address"`
}

121
internal/model/openai.go Normal file
View File

@@ -0,0 +1,121 @@
package model
// ChatCompletionRequest OpenAI Chat Completion请求
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
N *int `json:"n,omitempty"`
Stream bool `json:"stream,omitempty"`
Stop interface{} `json:"stop,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
LogitBias map[string]float64 `json:"logit_bias,omitempty"`
User string `json:"user,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
}
// Message 消息结构
type Message struct {
Role string `json:"role,omitempty"` // system, user, assistant, tool - omitempty让空字符串不被序列化
Content interface{} `json:"content,omitempty"`
Name string `json:"name,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// MessageContent 消息内容(支持文本和图片)
type MessageContent struct {
Type string `json:"type"` // text, image_url
Text string `json:"text,omitempty"`
ImageURL *ImageURLInfo `json:"image_url,omitempty"`
}
// ImageURLInfo 图片URL信息
type ImageURLInfo struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"` // auto, low, high
}
// Tool 工具定义
type Tool struct {
Type string `json:"type"` // function
Function FunctionDef `json:"function"`
}
// FunctionDef 函数定义
type FunctionDef struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters interface{} `json:"parameters,omitempty"`
}
// ToolCall 工具调用
type ToolCall struct {
Index *int `json:"index,omitempty"` // 流式响应中的索引
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"` // function
Function FunctionCall `json:"function"`
}
// FunctionCall 函数调用
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ChatCompletionResponse OpenAI Chat Completion响应
type ChatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
// Choice 选择项
type Choice struct {
Index int `json:"index"`
Message Message `json:"message,omitempty"`
Delta *Message `json:"delta,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
}
// Usage token使用情况
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ChatCompletionChunk 流式响应chunk
type ChatCompletionChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChunkChoice `json:"choices"`
}
// ChunkChoice 流式选择项
type ChunkChoice struct {
Index int `json:"index"`
Delta Message `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
}
// ErrorResponse 错误响应
type ErrorResponse struct {
Error ErrorDetail `json:"error"`
}
// ErrorDetail 错误详情
type ErrorDetail struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code,omitempty"`
}

59
internal/model/policy.go Normal file
View File

@@ -0,0 +1,59 @@
package model
// PolicyTicketRequest 获取ticket的请求
type PolicyTicketRequest struct {
LoginName string `json:"loginname"` // 用户名
UserKey string `json:"userkey"` // 密码加密
}
// PolicyTicketResponse 获取ticket的响应
type PolicyTicketResponse struct {
Code int `json:"code"` // 响应编码成功200
Message string `json:"message"` // 响应信息
Data *PolicyTicketData `json:"data"` // 响应数据
}
// PolicyTicketData ticket响应数据
type PolicyTicketData struct {
AppID string `json:"appid"` // appid
PrivateKey string `json:"privateKey"` // 私钥
SM4Key string `json:"sm4Key"` // SM4加密key
Ticket string `json:"ticket"` // Ticket有效时间1小时
}
// PolicyChatRequest 政策咨询对话请求
type PolicyChatRequest struct {
AppID string `json:"appid"` // 用户唯一标识
Ticket string `json:"ticket"` // 请求票据号
Data *PolicyChatData `json:"data"` // 接口入参信息
}
// PolicyChatData 对话请求数据
type PolicyChatData struct {
ChatID string `json:"chatId,omitempty"` // 会话ID首次调用为空
ConversationID string `json:"conversationId,omitempty"` // 流水号,首次调用为空
Stream bool `json:"stream"` // 流式访问类型
RealName bool `json:"realName"` // 是否实名
Message string `json:"message"` // 消息内容
MegType string `json:"megType"` // 消息类型
AAC001 string `json:"aac001,omitempty"` // 个人编号realName为true时必输
AAC147 string `json:"aac147,omitempty"` // 身份证号realName为true时必输
AAC003 string `json:"aac003,omitempty"` // 姓名realName为true时必输
ReqType string `json:"reqtype"` // 请求类型1、政策咨询
}
// PolicyChatResponse 政策咨询对话响应
type PolicyChatResponse struct {
Code int `json:"code"` // 响应编码成功200
Message string `json:"message"` // 响应信息
Data *PolicyChatResData `json:"data"` // 响应数据
}
// PolicyChatResData 对话响应数据
type PolicyChatResData struct {
ChatID string `json:"chatId"` // 会话ID
Message string `json:"message"` // 消息内容
ConversationID string `json:"conversationId,omitempty"` // 流水号
MegType string `json:"megType"` // 消息类型
Data interface{} `json:"data,omitempty"` // 查询数据结果
}

336
internal/model/tool.go Normal file
View File

@@ -0,0 +1,336 @@
package model
import (
"fmt"
"qd-sc/internal/config"
)
// GetAvailableTools 获取所有可用工具定义
func GetAvailableTools() []Tool {
cfg := config.Get()
cityName := cfg.City.Name
landmarks := cfg.City.GetLandmarksExample()
areaCodes := cfg.City.GetAreaCodesDescription()
return []Tool{
{
Type: "function",
Function: FunctionDef{
Name: "queryLocation",
Description: fmt.Sprintf("查询%s具体地点的经纬度坐标用于后续基于地理位置的岗位查询", cityName),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"keywords": map[string]interface{}{
"type": "string",
"description": fmt.Sprintf("具体的地名,例如:%s", landmarks),
},
},
"required": []string{"keywords"},
},
},
},
{
Type: "function",
Function: FunctionDef{
Name: "queryJobsByArea",
Description: fmt.Sprintf("【必须调用】根据区域代码查询%s岗位信息。当用户询问任何与岗位、工作、招聘、求职相关的问题时必须调用此工具获取真实数据。严禁在未调用此工具的情况下输出任何岗位信息。", cityName),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"jobTitle": map[string]interface{}{
"type": "string",
"description": "岗位名称关键字例如Java开发、产品经理",
},
"current": map[string]interface{}{
"type": "integer",
"description": "当前页码用于分页查询默认为1",
"default": 1,
},
"pageSize": map[string]interface{}{
"type": "integer",
"description": "每页返回的岗位数量默认为10",
"default": 10,
},
"jobLocationAreaCode": map[string]interface{}{
"type": "string",
"description": fmt.Sprintf("区域代码,%s", areaCodes),
},
"order": map[string]interface{}{
"type": "string",
"description": "排序方式0:推荐, 1:最热, 2:最新发布默认为0",
},
"minSalary": map[string]interface{}{
"type": "string",
"description": "最低薪资,单位:元/月",
},
"maxSalary": map[string]interface{}{
"type": "string",
"description": "最高薪资,单位:元/月",
},
"experience": map[string]interface{}{
"type": "string",
"description": "经验要求代码0:经验不限, 1:实习生, 2:应届毕业生, 3:1年以下, 4:1-3年, 5:3-5年, 6:5-10年, 7:10年以上",
},
"education": map[string]interface{}{
"type": "string",
"description": "学历要求代码,-1:不限, 0:初中及以下, 1:中专/中技, 2:高中, 3:大专, 4:本科, 5:硕士, 6:博士, 7:MBA/EMBA, 8:留学-学士, 9:留学-硕士, 10:留学-博士",
},
"companyNature": map[string]interface{}{
"type": "string",
"description": "企业类型代码1:私营企业, 2:股份制企业, 3:国有企业, 4:外商及港澳台投资企业, 5:医院",
},
},
"required": []string{"jobTitle", "current", "pageSize"},
},
},
},
{
Type: "function",
Function: FunctionDef{
Name: "queryJobsByLocation",
Description: fmt.Sprintf("【必须调用】根据经纬度和半径查询附近的%s岗位信息。当用户询问特定位置附近的岗位时必须调用此工具获取真实数据。需要先调用queryLocation获取经纬度。严禁在未调用此工具的情况下输出任何岗位信息。", cityName),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"jobTitle": map[string]interface{}{
"type": "string",
"description": "岗位名称关键字例如Java开发、产品经理",
},
"current": map[string]interface{}{
"type": "integer",
"description": "当前页码用于分页查询默认为1",
"default": 1,
},
"pageSize": map[string]interface{}{
"type": "integer",
"description": "每页返回的岗位数量默认为10",
"default": 10,
},
"latitude": map[string]interface{}{
"type": "string",
"description": "纬度从queryLocation工具获取",
},
"longitude": map[string]interface{}{
"type": "string",
"description": "经度从queryLocation工具获取",
},
"radius": map[string]interface{}{
"type": "string",
"description": "搜索半径单位千米最大为50建议使用5-10",
"default": "10",
},
"order": map[string]interface{}{
"type": "string",
"description": "排序方式0:推荐, 1:最热, 2:最新发布默认为0",
},
"minSalary": map[string]interface{}{
"type": "string",
"description": "最低薪资,单位:元/月",
},
"maxSalary": map[string]interface{}{
"type": "string",
"description": "最高薪资,单位:元/月",
},
"experience": map[string]interface{}{
"type": "string",
"description": "经验要求代码0:经验不限, 1:实习生, 2:应届毕业生, 3:1年以下, 4:1-3年, 5:3-5年, 6:5-10年, 7:10年以上",
},
"education": map[string]interface{}{
"type": "string",
"description": "学历要求代码,-1:不限, 0:初中及以下, 1:中专/中技, 2:高中, 3:大专, 4:本科, 5:硕士, 6:博士, 7:MBA/EMBA, 8:留学-学士, 9:留学-硕士, 10:留学-博士",
},
"companyNature": map[string]interface{}{
"type": "string",
"description": "企业类型代码1:私营企业, 2:股份制企业, 3:国有企业, 4:外商及港澳台投资企业, 5:医院",
},
},
"required": []string{"jobTitle", "current", "pageSize", "latitude", "longitude", "radius"},
},
},
},
{
Type: "function",
Function: FunctionDef{
Name: "parsePDF",
Description: "深度解析PDF文件提取文本内容特别适用于简历等复杂格式的PDF文件",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"fileUrl": map[string]interface{}{
"type": "string",
"description": "PDF文件的URL地址",
},
},
"required": []string{"fileUrl"},
},
},
},
{
Type: "function",
Function: FunctionDef{
Name: "parseImage",
Description: "解析图片文件,识别图片中的文本和内容,可用于识别简历截图、证书照片等",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"imageUrl": map[string]interface{}{
"type": "string",
"description": "图片文件的URL地址",
},
},
"required": []string{"imageUrl"},
},
},
},
{
Type: "function",
Function: FunctionDef{
Name: "queryPolicy",
Description: fmt.Sprintf("查询%s政策信息提供就业创业、社保医保、人才政策等方面的政策咨询服务", cityName),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"message": map[string]interface{}{
"type": "string",
"description": fmt.Sprintf("用户的政策咨询问题,例如:%s市大学生就业补贴政策、创业扶持政策、人才引进政策等", cityName),
},
"chatId": map[string]interface{}{
"type": "string",
"description": "会话ID用于多轮对话首次调用时不传或传空字符串",
},
"conversationId": map[string]interface{}{
"type": "string",
"description": "流水号,用于多轮对话,首次调用时不传或传空字符串",
},
"realName": map[string]interface{}{
"type": "boolean",
"description": "是否为实名咨询如果为true需要同时提供个人编号、身份证号和姓名",
"default": false,
},
"aac001": map[string]interface{}{
"type": "string",
"description": "个人编号当realName为true时必须提供",
},
"aac147": map[string]interface{}{
"type": "string",
"description": "身份证号当realName为true时必须提供",
},
"aac003": map[string]interface{}{
"type": "string",
"description": "姓名当realName为true时必须提供",
},
},
"required": []string{"message"},
},
},
},
}
}
// GetSystemPrompt 获取系统提示词
func GetSystemPrompt() string {
cfg := config.Get()
cityName := cfg.City.Name
areaCodes := cfg.City.GetAreaCodesDescription()
abbreviations := cfg.City.GetAbbreviationsDescription()
return fmt.Sprintf(`你是%s市智能岗位匹配助手负责处理用户上传的内容并调用相应工具提供岗位信息。请严格遵循以下操作流程
## ⚠️ 核心禁令(最高优先级,违反即为严重错误)
### 岗位信息绝对禁止自行编造
1. 【强制工具调用】当用户询问岗位、工作、招聘等相关信息时,**必须且只能**通过调用 queryJobsByArea 或 queryJobsByLocation 工具获取数据
2. 【绝对禁止】在未调用岗位查询工具的情况下,**严禁**输出任何岗位信息,包括但不限于:
- 岗位名称、公司名称、薪资范围、工作地点、学历要求、经验要求
- 任何看起来像岗位推荐的内容
- 根据用户描述"推测"或"举例"的岗位信息
3. 【数据来源唯一性】所有岗位数据**必须100%%%%**来自工具返回结果,不得添加、修改、臆测任何字段
4. 【格式强制】岗位信息的输出格式由系统自动处理,你**不需要也不允许**自行格式化岗位数据
### 违规行为示例(绝对禁止)
- ❌ "根据您的需求,我为您推荐以下岗位:前端开发工程师..." (未调用工具就输出岗位)
- ❌ "以下是一些可能适合您的岗位1. Java开发 薪资8000-12000..." (编造岗位信息)
- ❌ 将上一轮对话中的岗位信息作为本次回复(每次都必须重新调用工具)
- ❌ 用自己的格式输出岗位信息岗位名称xxx公司xxx
### 正确行为
- ✅ 收到岗位查询请求后,先调用 queryJobsByArea 或 queryJobsByLocation 工具
- ✅ 工具返回结果后,系统会自动以 job-json 格式展示,你只需要添加简短的引导语
- ✅ 如果工具返回空结果,如实告知用户未找到匹配岗位,建议调整条件
## 数据处理规则
1. 如果用户上传了文件,优先使用文件中提取的信息
2. 对于非传统简历(如普通文档、图片等),从中提取关键的求职相关信息:专业技能、工作经验、教育背景等
## 工具链思维模式
1. 【学习工具】仔细阅读每个工具的name、description和parameters理解工具的功能、输入和输出
2. 【依赖分析】识别工具之间的依赖关系:
- 某些工具的输入参数需要其他工具的输出(如:经纬度查询 → 基于经纬度的岗位查询)
- 某些工具可以独立使用,无需前置工具
3. 【路径规划】根据用户需求,自动规划最优的工具调用路径:
- 最短路径:用最少的工具调用完成任务
- 正确顺序:先调用前置工具,再调用依赖其结果的工具
- 完整覆盖:确保调用的工具链能完整回答用户问题
4. 【动态适应】当工具返回结果后,根据实际情况决定下一步:
- 如果已获得足够信息,立即回答用户并结束
- 如果需要更多信息,继续调用下一个必要的工具
- 如果某个环节失败,判断是否需要调整策略或重试
## 工具调用流程
1. 【理解用户需求】仔细分析用户问题,明确需要获取什么信息才能完整回答
2. 【规划工具链】根据可用工具的功能描述和参数要求,规划需要调用哪些工具以及调用顺序:
- 如果某个工具的输入参数依赖另一个工具的输出,先调用前置工具
- 如果多个工具可独立并行调用,按逻辑顺序依次调用
- 优先选择最直接、最高效的工具组合
3. 【执行工具链】按规划顺序调用工具,每个工具的输出可作为后续工具的输入
4. 【结果处理】岗位查询工具的结果由系统自动格式化展示,你无需手动处理
5. 【重试机制】仅在以下情况启用重试:
- 岗位查询类工具queryJobsByArea、queryJobsByLocation返回空结果时
- 可尝试调整查询参数(如关键词、半径、条件等)后重新查询
- 非岗位查询类工具如queryLocation、queryPolicy返回明确结果后不重试
6. 【停止条件】当完成用户请求所需的所有工具调用并获得足够信息后,立即回答并结束
7. 【严禁行为】
- 不要对已成功返回数据的工具进行无意义的重复调用
- 不要在展示了成功数据后又添加"尚未获取"等矛盾性表述
- 不要跳过必要的工具调用直接编造数据
- **绝对不要在未调用岗位工具的情况下输出任何岗位信息**
## 输出要求
1. 【数据真实性】所有展示的数据必须来自工具的实际返回结果,严禁编造或臆测
2. 【客观中立】保持简洁客观,作为工具调用的执行者和结果的传递者,不添加主观评价
3. 【友好提示】在调用工具前,用自然语言告知用户你将执行的操作(但不要使用"工具"、"API"等技术术语)
4. 【确定性回答】完成所有必要的工具调用并获得结果后,立即给出明确的答案,结束对话
5. 【岗位输出禁令】你**绝对不能**自行输出岗位信息,岗位数据的展示由系统在工具调用后自动完成
6. 【逻辑一致性】严禁出现自相矛盾的表述:
- 不要在展示成功获取的数据后,又说"尚未获取"、"查询失败"、"无法查询"
- 每次工具调用结果要么是成功(展示数据),要么是失败(说明原因),不能同时存在
- 如果工具返回了具体数据,就代表查询成功,无需再次确认或怀疑
7. 【岗位信息完整性】展示岗位时必须包含以下所有字段:岗位名称、公司名称、薪资、工作地点(区域)、学历要求、经验要求、详情链接。**不得省略任何字段特别是工作地点location字段**
## 工具特定说明
1. 【区域代码映射】%s市区域代码%s
2. 【多轮对话工具】某些工具支持多轮对话(如政策咨询),首次调用时不需要传入会话标识,后续调用时使用上次返回的标识以保持上下文
3. 【岗位查询强制规则】
- 进行任何岗位推荐时,**必须**调用 queryJobsByArea 或 queryJobsByLocation 工具
- 岗位信息展示由系统自动完成,你只需提供简短引导语
- **严禁**在未调用工具的情况下输出任何岗位相关数据
## 特别注意
1. 【语义理解】理解用户输入的隐含含义和简称(如%s在调用工具时使用准确完整的表达
2. 【工具适用性】并非所有问题都需要调用工具,常规咨询问题(如"面试技巧")可直接回答
3. 【自然表达】使用自然语言与用户交流,不要提及"工具"、"API"、"函数调用"等技术术语
4. 【工具协同】不同类型的工具可以组合使用,形成完整的解决方案(如政策咨询+岗位推荐)
5. 【精准调用】每个工具在一次任务中通常只需调用一次,除非是有意义的重试(如岗位查询空结果时换关键词)
6. 【二元结果】工具调用结果必须是明确的二元状态:
- 成功:展示返回的数据,给出肯定答复
- 失败:说明失败原因,建议解决方案
- 严禁同时出现成功和失败的矛盾表述
## 自检清单(每次回复前必须确认)
在输出任何岗位相关内容前,请自问:
1. 我是否已经调用了 queryJobsByArea 或 queryJobsByLocation 工具?
2. 我即将输出的岗位信息是否100%%%%来自工具返回结果?
3. 我是否在尝试自行格式化或编造岗位数据?
如果第1、2题答案为"否"或第3题答案为"是",则**立即停止**,先调用工具获取数据。`, cityName, cityName, areaCodes, abbreviations)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
package service
import (
"encoding/json"
"fmt"
"qd-sc/internal/client"
"qd-sc/internal/config"
"qd-sc/internal/model"
"qd-sc/pkg/utils"
)
// JobService 岗位服务
type JobService struct {
cfg *config.Config
jobClient *client.JobClient
}
// NewJobService 创建岗位服务
func NewJobService(cfg *config.Config, jobClient *client.JobClient) *JobService {
return &JobService{
cfg: cfg,
jobClient: jobClient,
}
}
// QueryJobsByArea 根据区域代码查询岗位
func (s *JobService) QueryJobsByArea(params map[string]interface{}) (string, error) {
return s.queryJobs(params)
}
// QueryJobsByLocation 根据经纬度查询岗位
func (s *JobService) QueryJobsByLocation(params map[string]interface{}) (string, error) {
return s.queryJobs(params)
}
// queryJobs 通用岗位查询方法
func (s *JobService) queryJobs(params map[string]interface{}) (string, error) {
req := s.buildJobQueryRequest(params)
apiResp, err := s.jobClient.QueryJobs(req)
if err != nil {
return "", fmt.Errorf("查询岗位失败: %w", err)
}
if apiResp.Code != 200 {
errMsg := apiResp.Msg
if errMsg == "" {
errMsg = fmt.Sprintf("API返回错误代码: %d", apiResp.Code)
}
return "", fmt.Errorf("岗位API返回错误: %s", errMsg)
}
if len(apiResp.Rows) == 0 {
return s.formatEmptyResult(), nil
}
// 格式化响应
formattedResp := s.jobClient.FormatJobResponse(apiResp)
return s.formatJobResponse(formattedResp)
}
// buildJobQueryRequest 构建岗位查询请求
func (s *JobService) buildJobQueryRequest(params map[string]interface{}) *model.JobQueryRequest {
req := &model.JobQueryRequest{
Current: 1,
PageSize: 10,
}
// 解析参数
if v, ok := params["current"].(float64); ok {
req.Current = int(v)
}
if v, ok := params["pageSize"].(float64); ok {
req.PageSize = int(v)
}
if v, ok := params["jobTitle"].(string); ok {
req.JobTitle = v
}
if v, ok := params["latitude"].(string); ok {
req.Latitude = v
}
if v, ok := params["longitude"].(string); ok {
req.Longitude = v
}
if v, ok := params["radius"].(string); ok {
req.Radius = v
}
if v, ok := params["order"].(string); ok {
req.Order = v
}
if v, ok := params["minSalary"].(string); ok {
req.MinSalary = v
}
if v, ok := params["maxSalary"].(string); ok {
req.MaxSalary = v
}
if v, ok := params["experience"].(string); ok {
req.Experience = v
}
if v, ok := params["education"].(string); ok {
req.Education = v
}
if v, ok := params["companyNature"].(string); ok {
req.CompanyNature = v
}
if v, ok := params["jobLocationAreaCode"].(string); ok {
req.JobLocationAreaCode = v
}
return req
}
// formatJobResponse 格式化岗位响应为JSON字符串
func (s *JobService) formatJobResponse(resp *model.JobResponse) (string, error) {
// 将响应格式化为JSON
jsonStr, err := utils.ToJSONStringPretty(resp)
if err != nil {
return "", fmt.Errorf("格式化岗位信息失败: %w", err)
}
return jsonStr, nil
}
// formatEmptyResult 格式化空结果
func (s *JobService) formatEmptyResult() string {
emptyResp := &model.JobResponse{
JobListings: []model.FormattedJob{},
Data: nil,
}
jsonStr, _ := json.MarshalIndent(emptyResp, "", " ")
return string(jsonStr)
}

View File

@@ -0,0 +1,30 @@
package service
import (
"fmt"
"qd-sc/internal/client"
"qd-sc/internal/config"
)
// LocationService 地理位置服务
type LocationService struct {
cfg *config.Config
amapClient *client.AmapClient
}
// NewLocationService 创建位置服务
func NewLocationService(cfg *config.Config, amapClient *client.AmapClient) *LocationService {
return &LocationService{
cfg: cfg,
amapClient: amapClient,
}
}
// QueryLocation 查询地点经纬度
func (s *LocationService) QueryLocation(keywords string) (latitude, longitude string, err error) {
lat, lng, err := s.amapClient.GetLocationCoordinates(keywords)
if err != nil {
return "", "", fmt.Errorf("查询地点失败: %w", err)
}
return lat, lng, nil
}

View File

@@ -0,0 +1,58 @@
package service
import (
"fmt"
"qd-sc/internal/client"
"qd-sc/internal/config"
"qd-sc/internal/model"
)
// PolicyService 政策咨询服务
type PolicyService struct {
policyClient *client.PolicyClient
}
// NewPolicyService 创建政策咨询服务
func NewPolicyService(cfg *config.Config) *PolicyService {
return &PolicyService{
policyClient: client.NewPolicyClient(cfg),
}
}
// QueryPolicy 查询政策信息
func (s *PolicyService) QueryPolicy(
message string,
chatID string,
conversationID string,
realName bool,
aac001 string,
aac147 string,
aac003 string,
) (string, string, string, error) {
// 构建请求
chatReq := &model.PolicyChatData{
ChatID: chatID,
ConversationID: conversationID,
Stream: false,
RealName: realName,
Message: message,
MegType: "MESSAGE",
AAC001: aac001,
AAC147: aac147,
AAC003: aac003,
ReqType: "1", // 1表示政策咨询
}
// 调用政策大模型接口
resp, err := s.policyClient.Chat(chatReq)
if err != nil {
return "", "", "", fmt.Errorf("政策咨询失败: %w", err)
}
if resp.Data == nil {
return "", "", "", fmt.Errorf("政策咨询返回数据为空")
}
// 返回消息内容、chatID和conversationID供下次调用使用
return resp.Data.Message, resp.Data.ChatID, resp.Data.ConversationID, nil
}