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