init
This commit is contained in:
90
internal/client/amap_client.go
Normal file
90
internal/client/amap_client.go
Normal 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
|
||||
}
|
||||
63
internal/client/http_client.go
Normal file
63
internal/client/http_client.go
Normal 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)
|
||||
}
|
||||
176
internal/client/job_client.go
Normal file
176
internal/client/job_client.go
Normal 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,
|
||||
}
|
||||
}
|
||||
156
internal/client/llm_client.go
Normal file
156
internal/client/llm_client.go
Normal 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
|
||||
}
|
||||
75
internal/client/llm_client_test.go
Normal file
75
internal/client/llm_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
113
internal/client/ocr_client.go
Normal file
113
internal/client/ocr_client.go
Normal 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
|
||||
}
|
||||
256
internal/client/policy_client.go
Normal file
256
internal/client/policy_client.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user