init
This commit is contained in:
44
.dockerignore
Normal file
44
.dockerignore
Normal file
@@ -0,0 +1,44 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
qd-sc-server
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Uploads and data
|
||||
uploads/
|
||||
*.log
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Config (will be mounted)
|
||||
# config.yaml
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# CI/CD
|
||||
Makefile
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test files
|
||||
*_test.go
|
||||
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Application specific
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
config.yaml
|
||||
config.local.yaml
|
||||
*.log
|
||||
|
||||
# Build output
|
||||
qd-sc-server
|
||||
qd-sc-server.exe
|
||||
dist/
|
||||
build/
|
||||
|
||||
1755
API_DOCS.md
Normal file
1755
API_DOCS.md
Normal file
File diff suppressed because it is too large
Load Diff
399
CODE_ARCHITECTURE.md
Normal file
399
CODE_ARCHITECTURE.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# 青岛岗位匹配系统 - 代码架构文档
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 Go 语言开发的智能岗位匹配系统,提供兼容 OpenAI `/v1/chat/completions` API 的接口。系统集成了 LLM 大模型、高德地图、OCR 文件解析、政策咨询等多种服务。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
qd-sc/
|
||||
├── cmd/server/main.go # 应用入口
|
||||
├── internal/ # 内部包(不对外暴露)
|
||||
│ ├── api/ # API 层
|
||||
│ │ ├── handler/ # HTTP 请求处理器
|
||||
│ │ └── middleware/ # 中间件
|
||||
│ ├── client/ # 外部服务客户端
|
||||
│ ├── config/ # 配置管理
|
||||
│ ├── model/ # 数据模型定义
|
||||
│ └── service/ # 业务逻辑层
|
||||
├── pkg/ # 公共包(可对外暴露)
|
||||
│ ├── metrics/ # 性能指标收集
|
||||
│ └── utils/ # 工具函数
|
||||
└── config.yaml # 配置文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块详解
|
||||
|
||||
### 1. 入口模块 (`cmd/server/main.go`)
|
||||
|
||||
**职责**:应用启动、依赖注入、路由配置、优雅关闭
|
||||
|
||||
**核心流程**:
|
||||
1. 加载配置文件(支持 `-config` 参数指定路径)
|
||||
2. 初始化各类客户端(LLM、高德、岗位、OCR)
|
||||
3. 初始化服务层(位置、岗位、政策、聊天)
|
||||
4. 配置 Gin 路由和中间件
|
||||
5. 启动 HTTP 服务器
|
||||
6. 监听系统信号,实现优雅关闭
|
||||
|
||||
**路由配置**:
|
||||
| 路径 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | GET | API 信息 |
|
||||
| `/health` | GET | 健康检查 |
|
||||
| `/metrics` | GET | 性能指标 |
|
||||
| `/v1/chat/completions` | POST | 聊天接口(主接口) |
|
||||
| `/debug/pprof/*` | GET | 性能分析 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 配置模块 (`internal/config/`)
|
||||
|
||||
**文件**:`config.go`
|
||||
|
||||
**职责**:配置加载、环境变量覆盖、默认值设置
|
||||
|
||||
**配置结构**:
|
||||
```go
|
||||
type Config struct {
|
||||
Server ServerConfig // 服务器配置(端口、超时)
|
||||
LLM LLMConfig // LLM 配置(API地址、密钥、模型)
|
||||
Amap AmapConfig // 高德地图配置
|
||||
JobAPI JobAPIConfig // 岗位 API 配置
|
||||
OCR OCRConfig // OCR 服务配置
|
||||
Policy PolicyConfig // 政策咨询配置
|
||||
Logging LoggingConfig // 日志配置
|
||||
Performance PerformanceConfig // 性能配置
|
||||
}
|
||||
```
|
||||
|
||||
**环境变量支持**:
|
||||
- `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`
|
||||
- `AMAP_API_KEY`
|
||||
- `OCR_BASE_URL`
|
||||
- `POLICY_LOGIN_NAME` / `POLICY_USER_KEY` / `POLICY_SERVICE_ID`
|
||||
- `SERVER_PORT`
|
||||
|
||||
---
|
||||
|
||||
### 3. 数据模型 (`internal/model/`)
|
||||
|
||||
#### 3.1 `openai.go` - OpenAI 兼容模型
|
||||
|
||||
定义与 OpenAI API 兼容的请求/响应结构:
|
||||
|
||||
- `ChatCompletionRequest` - 聊天请求
|
||||
- `ChatCompletionResponse` - 聊天响应
|
||||
- `ChatCompletionChunk` - 流式响应块
|
||||
- `Message` - 消息结构(支持文本和多模态)
|
||||
- `Tool` / `ToolCall` - 工具调用相关
|
||||
- `ErrorResponse` - 错误响应
|
||||
|
||||
#### 3.2 `job.go` - 岗位相关模型
|
||||
|
||||
- `JobQueryRequest` - 岗位查询请求参数
|
||||
- `JobAPIResponse` - 岗位 API 原始响应
|
||||
- `JobListing` - 单个岗位信息
|
||||
- `FormattedJob` - 格式化后的岗位信息
|
||||
- `EducationMap` / `ExperienceMap` / `LocationMap` - 代码映射表
|
||||
|
||||
#### 3.3 `policy.go` - 政策咨询模型
|
||||
|
||||
- `PolicyTicketRequest/Response` - Ticket 获取
|
||||
- `PolicyChatRequest/Response` - 政策对话
|
||||
|
||||
#### 3.4 `tool.go` - 工具定义
|
||||
|
||||
**`GetAvailableTools()`** - 返回所有可用工具:
|
||||
|
||||
| 工具名 | 功能 |
|
||||
|--------|------|
|
||||
| `queryLocation` | 查询地点经纬度(高德地图) |
|
||||
| `queryJobsByArea` | 按区域代码查询岗位 |
|
||||
| `queryJobsByLocation` | 按经纬度查询附近岗位 |
|
||||
| `parsePDF` | 解析 PDF 文件 |
|
||||
| `parseImage` | 解析图片文件 |
|
||||
| `queryPolicy` | 政策咨询 |
|
||||
|
||||
**`SystemPrompt`** - 系统提示词,定义 AI 助手的行为规范:
|
||||
- 强制调用工具获取岗位数据,禁止编造
|
||||
- 工具链思维模式
|
||||
- 输出格式要求
|
||||
|
||||
---
|
||||
|
||||
### 4. 客户端层 (`internal/client/`)
|
||||
|
||||
#### 4.1 `http_client.go` - HTTP 客户端工厂
|
||||
|
||||
提供优化的 HTTP 客户端配置:
|
||||
- 连接池管理
|
||||
- HTTP/2 支持
|
||||
- 超时配置
|
||||
- 缓冲区优化
|
||||
|
||||
```go
|
||||
NewHTTPClient(config) // 通用客户端
|
||||
NewLLMHTTPClient(timeout) // LLM 专用(更大连接池)
|
||||
```
|
||||
|
||||
#### 4.2 `llm_client.go` - LLM 客户端
|
||||
|
||||
**方法**:
|
||||
- `ChatCompletion()` - 非流式请求(带重试机制)
|
||||
- `ChatCompletionStream()` - 流式请求(返回 channel)
|
||||
|
||||
**特性**:
|
||||
- 自动重试(5xx 和 429 错误)
|
||||
- SSE 流式解析
|
||||
- 大缓冲区防止超大响应
|
||||
|
||||
#### 4.3 `amap_client.go` - 高德地图客户端
|
||||
|
||||
**方法**:
|
||||
- `SearchPlace(keywords)` - 搜索地点
|
||||
- `GetLocationCoordinates(keywords)` - 获取经纬度
|
||||
|
||||
#### 4.4 `job_client.go` - 岗位 API 客户端
|
||||
|
||||
**方法**:
|
||||
- `QueryJobs(req)` - 查询岗位列表
|
||||
- `FormatJobResponse(apiResp)` - 格式化响应(代码转文字)
|
||||
|
||||
#### 4.5 `ocr_client.go` - OCR 服务客户端
|
||||
|
||||
**方法**:
|
||||
- `ParseURL(fileURL)` - 解析远程文件(图片/PDF/Excel/PPT)
|
||||
|
||||
#### 4.6 `policy_client.go` - 政策咨询客户端
|
||||
|
||||
**方法**:
|
||||
- `GetTicket()` - 获取访问票据(自动缓存,1小时有效)
|
||||
- `Chat(chatReq)` - 非流式对话
|
||||
- `ChatStream(chatReq)` - 流式对话
|
||||
|
||||
---
|
||||
|
||||
### 5. 服务层 (`internal/service/`)
|
||||
|
||||
#### 5.1 `chat_service.go` - 核心聊天服务
|
||||
|
||||
**职责**:消息处理、工具调用编排、流式输出
|
||||
|
||||
**核心方法**:
|
||||
|
||||
```go
|
||||
ProcessChatRequest(req) // 非流式处理
|
||||
ProcessChatRequestStream(ctx, req) // 流式处理
|
||||
```
|
||||
|
||||
**关键特性**:
|
||||
|
||||
1. **岗位意图检测** (`isJobQueryIntent`)
|
||||
- 关键词匹配检测用户是否在查询岗位
|
||||
- 检测到岗位意图时强制调用工具
|
||||
|
||||
2. **幻觉检测** (`containsJobHallucination`)
|
||||
- 正则匹配检测 AI 是否自行编造岗位信息
|
||||
- 拦截幻觉输出,强制重新调用工具
|
||||
|
||||
3. **简历检测** (`isResumeContent`)
|
||||
- 关键词匹配判断 OCR 内容是否为简历
|
||||
- 非简历内容会提示先询问用户意图
|
||||
|
||||
4. **工具调用循环**
|
||||
- 最多 10 轮工具调用
|
||||
- 自动合并流式响应中的分块工具调用
|
||||
- 岗位结果分块输出(每个岗位间隔 1 秒)
|
||||
|
||||
5. **消息预处理** (`prepareMessages`)
|
||||
- 注入系统提示词
|
||||
- 处理 Vision API 格式的文件 URL
|
||||
- 自动调用 OCR 解析文件
|
||||
|
||||
#### 5.2 `job_service.go` - 岗位服务
|
||||
|
||||
**方法**:
|
||||
- `QueryJobsByArea(params)` - 按区域查询
|
||||
- `QueryJobsByLocation(params)` - 按位置查询
|
||||
|
||||
#### 5.3 `location_service.go` - 位置服务
|
||||
|
||||
**方法**:
|
||||
- `QueryLocation(keywords)` - 查询地点坐标
|
||||
|
||||
#### 5.4 `policy_service.go` - 政策服务
|
||||
|
||||
**方法**:
|
||||
- `QueryPolicy(...)` - 政策咨询(支持多轮对话和实名咨询)
|
||||
|
||||
---
|
||||
|
||||
### 6. API 处理器 (`internal/api/handler/`)
|
||||
|
||||
#### 6.1 `chat.go` - 聊天处理器
|
||||
|
||||
**方法**:
|
||||
- `ChatCompletions(c)` - 主接口处理
|
||||
- 验证请求参数
|
||||
- 根据 `stream` 参数分发到流式/非流式处理
|
||||
|
||||
**流式响应**:
|
||||
- SSE 格式输出
|
||||
- 支持客户端断开检测
|
||||
- 错误时发送错误 chunk
|
||||
|
||||
#### 6.2 `health.go` - 健康检查
|
||||
|
||||
返回服务状态信息。
|
||||
|
||||
#### 6.3 `metrics.go` - 指标处理器
|
||||
|
||||
返回性能统计数据。
|
||||
|
||||
#### 6.4 `response.go` - 统一响应
|
||||
|
||||
提供 `Error()` 和 `Success()` 方法统一响应格式。
|
||||
|
||||
---
|
||||
|
||||
### 7. 中间件 (`internal/api/middleware/`)
|
||||
|
||||
#### 7.1 `cors.go` - 跨域处理
|
||||
|
||||
- 支持携带凭证的请求
|
||||
- 预检请求处理
|
||||
- 24 小时缓存
|
||||
|
||||
#### 7.2 `ratelimit.go` - 限流
|
||||
|
||||
**算法**:令牌桶(Token Bucket)
|
||||
|
||||
**配置**:
|
||||
- 桶容量:200(最大突发)
|
||||
- 补充速率:50/秒(持续 QPS)
|
||||
|
||||
**实现**:CAS 无锁算法,高并发友好
|
||||
|
||||
#### 7.3 `recovery.go` - Panic 恢复
|
||||
|
||||
捕获 panic,打印堆栈,返回 500 错误。
|
||||
|
||||
#### 7.4 `metrics.go` - 指标收集
|
||||
|
||||
记录请求数、延迟、失败数等指标。
|
||||
|
||||
---
|
||||
|
||||
### 8. 工具包 (`pkg/`)
|
||||
|
||||
#### 8.1 `metrics/metrics.go` - 性能指标
|
||||
|
||||
**收集指标**:
|
||||
- 请求计数(总数、活跃、失败、流式)
|
||||
- 延迟统计(平均、最小、最大)
|
||||
- 系统指标(goroutine、内存、GC)
|
||||
|
||||
**实现**:原子操作 + sync.Map,无锁高性能
|
||||
|
||||
#### 8.2 `utils/json.go` - JSON 工具
|
||||
|
||||
`ToJSONStringPretty()` - 格式化 JSON 输出
|
||||
|
||||
---
|
||||
|
||||
## 数据流图
|
||||
|
||||
```
|
||||
用户请求
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Gin Router │
|
||||
│ + 中间件 │
|
||||
│ (CORS/限流/恢复)│
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ ChatHandler │
|
||||
│ (请求验证) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ ChatService │◄──────────────────────┐
|
||||
│ (核心编排) │ │
|
||||
└────────┬────────┘ │
|
||||
│ │
|
||||
┌────┴────┬────────┬────────┐ │
|
||||
▼ ▼ ▼ ▼ │
|
||||
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │
|
||||
│LLM │ │Job │ │Location│ │Policy │ │
|
||||
│Client │ │Service│ │Service │ │Service│ │
|
||||
└───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │
|
||||
│ │ │ │ │
|
||||
▼ ▼ ▼ ▼ │
|
||||
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │
|
||||
│OpenAI │ │岗位API │ │高德API │ │政策API │ │
|
||||
│ API │ │ │ │ │ │ │ │
|
||||
└───────┘ └───────┘ └───────┘ └───────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
(工具调用循环)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计
|
||||
|
||||
### 1. 工具调用机制
|
||||
|
||||
系统通过 LLM 的 Function Calling 能力实现智能工具调用:
|
||||
|
||||
1. 用户发送消息
|
||||
2. LLM 分析意图,决定调用哪些工具
|
||||
3. 服务端执行工具调用,获取真实数据
|
||||
4. 将工具结果返回给 LLM
|
||||
5. LLM 基于真实数据生成回复
|
||||
|
||||
### 2. 幻觉防护
|
||||
|
||||
为防止 LLM 编造岗位信息:
|
||||
|
||||
1. **意图检测**:检测到岗位查询时设置 `tool_choice=required`
|
||||
2. **输出检测**:正则匹配检测疑似编造的岗位信息
|
||||
3. **强制重试**:检测到幻觉时强制重新调用工具
|
||||
|
||||
### 3. 流式输出优化
|
||||
|
||||
- 岗位信息分块输出,每个岗位间隔 1 秒
|
||||
- 使用 `job-json` 代码块格式,便于前端渲染
|
||||
- 过滤 tool_calls 相关 chunk,对客户端透明
|
||||
|
||||
### 4. 连接池优化
|
||||
|
||||
- LLM 客户端:200 最大连接,100/host
|
||||
- 其他客户端:100 最大连接,50/host
|
||||
- 启用 HTTP/2
|
||||
- 32KB 读写缓冲
|
||||
|
||||
---
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 添加新工具
|
||||
|
||||
1. 在 `model/tool.go` 的 `GetAvailableTools()` 中添加工具定义
|
||||
2. 在 `service/chat_service.go` 的 `executeToolCall()` 中添加处理分支
|
||||
3. 实现对应的处理函数
|
||||
|
||||
### 添加新的外部服务
|
||||
|
||||
1. 在 `config/config.go` 中添加配置结构
|
||||
2. 在 `client/` 下创建新的客户端
|
||||
3. 在 `service/` 下创建对应的服务层
|
||||
4. 在 `main.go` 中初始化并注入
|
||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
# 构建阶段(支持 buildx 多架构)
|
||||
FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件(优先复制,利用 Docker 缓存)
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# 仅复制必要的源代码目录
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
COPY pkg/ ./pkg/
|
||||
|
||||
# 编译 - 优化参数减小二进制大小和加快编译
|
||||
RUN CGO_ENABLED=0 \
|
||||
GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-$(go env GOARCH)} \
|
||||
go build -ldflags="-s -w" -trimpath \
|
||||
-o qd-sc-server cmd/server/main.go
|
||||
|
||||
# 运行阶段 - 使用最小化的 alpine
|
||||
FROM alpine:3.19
|
||||
|
||||
# 安装必要的工具(ca-certificates 用于 HTTPS,wget 用于 healthcheck)
|
||||
RUN apk --no-cache add ca-certificates tzdata wget && \
|
||||
addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser && \
|
||||
mkdir -p /app && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 从构建阶段复制二进制文件
|
||||
COPY --from=builder --chown=appuser:appuser /app/qd-sc-server .
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER appuser
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 运行
|
||||
ENTRYPOINT ["./qd-sc-server"]
|
||||
CMD ["-config", "/app/config.yaml"]
|
||||
|
||||
426
README.md
Normal file
426
README.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# 青岛岗位匹配系统
|
||||
|
||||
基于Go开发的智能岗位匹配系统,兼容OpenAI `/v1/chat/completions` API接口。
|
||||
|
||||
## 配置文档
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
编辑 `config.yaml`:
|
||||
|
||||
```yaml
|
||||
# 服务器配置
|
||||
server:
|
||||
port: 8080 # 服务端口
|
||||
host: "0.0.0.0" # 监听地址
|
||||
read_timeout: 30s # 读取请求超时
|
||||
write_timeout: 300s # 写入响应超时(流式响应需要更长时间)
|
||||
|
||||
# LLM配置
|
||||
llm:
|
||||
base_url: "https://your-llm-api.com/v1" # LLM API地址
|
||||
api_key: "sk-xxx" # LLM API密钥
|
||||
model: "gpt-4o" # 默认模型
|
||||
timeout: 120s # 请求超时
|
||||
max_retries: 3 # 最大重试次数
|
||||
|
||||
# 高德地图配置
|
||||
amap:
|
||||
api_key: "your-amap-key" # 高德地图API密钥
|
||||
base_url: "https://restapi.amap.com/v3" # 高德API地址
|
||||
timeout: 10s # 请求超时
|
||||
|
||||
# 岗位API配置
|
||||
job_api:
|
||||
base_url: "https://job-api.example.com" # 岗位API地址
|
||||
timeout: 30s # 请求超时
|
||||
|
||||
# OCR服务配置(文件解析)
|
||||
ocr:
|
||||
base_url: "https://your-ocr-api.example.com" # OCR服务地址(外网)
|
||||
# base_url: "http://127.0.0.1:9001" # OCR服务地址(内网)
|
||||
timeout: 120s # 请求超时
|
||||
|
||||
# 政策咨询配置
|
||||
policy:
|
||||
base_url: "http://policy-api.example.com" # 政策API地址
|
||||
login_name: "your_login_name" # 登录用户名
|
||||
user_key: "your_user_key" # 用户密钥
|
||||
service_id: "your_service_id" # 服务ID
|
||||
timeout: 60s # 请求超时
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level: "info" # 日志级别:debug, info, warn, error
|
||||
format: "json" # 日志格式:json, text
|
||||
|
||||
# 性能配置
|
||||
performance:
|
||||
max_goroutines: 10000 # 最大并发goroutine数
|
||||
goroutine_pool_size: 5000 # goroutine池大小
|
||||
task_queue_size: 10000 # 任务队列大小
|
||||
enable_pprof: true # 启用pprof性能分析(设为 false 可关闭 /debug/pprof/*)
|
||||
enable_metrics: true # 启用指标收集(设为 false 可关闭 /metrics 与指标中间件)
|
||||
gc_percent: 100 # GC触发百分比
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
环境变量会自动覆盖配置文件中的值(推荐生产环境使用):
|
||||
|
||||
```bash
|
||||
# 服务器
|
||||
export SERVER_PORT="8080"
|
||||
export SERVER_HOST="0.0.0.0"
|
||||
|
||||
# LLM
|
||||
export LLM_API_KEY="sk-xxx"
|
||||
export LLM_BASE_URL="https://your-llm-api.com/v1"
|
||||
export LLM_MODEL="gpt-4o"
|
||||
|
||||
# 高德地图
|
||||
export AMAP_API_KEY="your-amap-key"
|
||||
|
||||
# OCR服务
|
||||
export OCR_BASE_URL="https://your-ocr-api.example.com"
|
||||
|
||||
# 岗位API
|
||||
export JOB_API_BASE_URL="https://job-api.example.com"
|
||||
|
||||
# 政策API
|
||||
export POLICY_BASE_URL="http://policy-api.example.com"
|
||||
export POLICY_LOGIN_NAME="your_login_name"
|
||||
export POLICY_USER_KEY="your_user_key"
|
||||
export POLICY_SERVICE_ID="your_service_id"
|
||||
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
go mod download
|
||||
|
||||
# 直接运行
|
||||
go run cmd/server/main.go
|
||||
|
||||
# 或编译后运行
|
||||
make build
|
||||
./qd-sc-server
|
||||
|
||||
# 使用自定义配置文件
|
||||
./qd-sc-server -config=/path/to/config.yaml
|
||||
```
|
||||
|
||||
服务启动在 `http://localhost:8080`
|
||||
|
||||
## API端点
|
||||
|
||||
系统提供以下端点:
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | GET | API信息和端点列表 |
|
||||
| `/health` | GET | 健康检查 |
|
||||
| `/metrics` | GET | 性能指标(需启用 `performance.enable_metrics`) |
|
||||
| `/v1/chat/completions` | POST | OpenAI兼容的聊天接口(主要接口) |
|
||||
| `/debug/pprof/*` | GET | 性能分析(pprof,需启用 `performance.enable_pprof`) |
|
||||
|
||||
## API调用
|
||||
|
||||
### 1. 普通对话
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "qd-job-turbo",
|
||||
"messages": [
|
||||
{"role": "user", "content": "帮我推荐城阳区的Java开发岗位"}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. 带文件URL(Vision API 兼容格式)
|
||||
|
||||
通过 `image_url` 字段发送文件 URL,支持图片、PDF、Excel、PPT 等格式:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "qd-job-turbo",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "根据这份简历推荐岗位"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/resume.pdf"}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
> **说明**: `image_url` 是 OpenAI Vision API 的兼容字段名,实际支持图片、PDF、Excel、PPT 等多种文件格式。
|
||||
|
||||
### 3. 多轮对话
|
||||
|
||||
支持上下文对话,只需在 `messages` 中包含历史消息:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "qd-job-turbo",
|
||||
"messages": [
|
||||
{"role": "user", "content": "我想找Java开发岗位"},
|
||||
{"role": "assistant", "content": "好的,请问您希望在哪个区域找工作?"},
|
||||
{"role": "user", "content": "城阳区"}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. 请求参数说明
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "qd-job-turbo", // 必填:固定模型名称
|
||||
"messages": [ // 必填:对话消息列表
|
||||
{
|
||||
"role": "user", // 角色:user, assistant, system
|
||||
"content": "你的问题" // 消息内容
|
||||
}
|
||||
],
|
||||
"stream": true, // 可选:是否流式输出(推荐true)
|
||||
"temperature": 0.7, // 可选:温度参数(0-2)
|
||||
"max_tokens": 2000, // 可选:最大生成token数
|
||||
"top_p": 1.0, // 可选:nucleus采样参数
|
||||
"presence_penalty": 0.0, // 可选:存在惩罚
|
||||
"frequency_penalty": 0.0 // 可选:频率惩罚
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 响应格式
|
||||
|
||||
**流式响应**(`stream: true`):
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qd-job-turbo","choices":[{"index":0,"delta":{"role":"assistant","content":"您好"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qd-job-turbo","choices":[{"index":0,"delta":{"content":","},"finish_reason":null}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
**非流式响应**(`stream: false`):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-xxx",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "qd-job-turbo",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "您好,我可以帮您推荐岗位..."
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 150
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 内置功能
|
||||
|
||||
系统会根据对话自动调用以下工具:
|
||||
|
||||
1. **queryLocation** - 查询地点坐标(高德地图)
|
||||
2. **queryJobsByArea** - 按区域查询岗位
|
||||
3. **queryJobsByLocation** - 按坐标查询岗位
|
||||
4. **queryPolicy** - 政策咨询
|
||||
5. **parsePDF** - PDF解析(OCR服务)
|
||||
6. **parseImage** - 图片识别(OCR服务)
|
||||
|
||||
### 工具参数说明
|
||||
|
||||
#### queryJobsByArea(按区域查询岗位)
|
||||
|
||||
```json
|
||||
{
|
||||
"area": 5, // 区域代码(0-9)
|
||||
"keyword": "Java", // 可选:关键词
|
||||
"education": 4, // 可选:学历代码
|
||||
"experience": 5, // 可选:经验代码
|
||||
"page": 1, // 可选:页码
|
||||
"pageSize": 20 // 可选:每页数量
|
||||
}
|
||||
```
|
||||
|
||||
#### queryJobsByLocation(按坐标查询岗位)
|
||||
|
||||
```json
|
||||
{
|
||||
"latitude": "36.307527", // 纬度
|
||||
"longitude": "120.467121", // 经度
|
||||
"keyword": "Java", // 可选:关键词
|
||||
"radius": 5000, // 可选:搜索半径(米)
|
||||
"education": 4, // 可选:学历代码
|
||||
"experience": 5 // 可选:经验代码
|
||||
}
|
||||
```
|
||||
|
||||
#### queryPolicy(政策咨询)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "咨询问题", // 必填:咨询内容
|
||||
"chatId": "xxx", // 可选:会话ID(多轮对话)
|
||||
"conversationId": "xxx", // 可选:对话ID(多轮对话)
|
||||
"realName": false, // 可选:是否实名咨询
|
||||
"aac001": "个人编号", // 实名时必填
|
||||
"aac147": "身份证号", // 实名时必填
|
||||
"aac003": "姓名" // 实名时必填
|
||||
}
|
||||
```
|
||||
|
||||
### 代码对照表
|
||||
|
||||
#### 区域代码
|
||||
|
||||
- 0:市南区, 1:市北区, 2:李沧区, 3:崂山区, 4:黄岛区
|
||||
- 5:城阳区, 6:即墨区, 7:胶州市, 8:平度市, 9:莱西市
|
||||
|
||||
#### 学历代码
|
||||
|
||||
- -1:不限, 0:初中及以下, 1:中专/中技, 2:高中, 3:大专
|
||||
- 4:本科, 5:硕士, 6:博士, 7:MBA/EMBA, 8-10:留学
|
||||
|
||||
#### 经验代码
|
||||
|
||||
- 0:不限, 1:实习生, 2:应届, 3:1年以下
|
||||
- 4:1-3年, 5:3-5年, 6:5-10年, 7:10年以上
|
||||
|
||||
## 其他端点
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 性能指标
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/metrics
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"requests_total": 1234,
|
||||
"requests_success": 1200,
|
||||
"requests_failed": 34,
|
||||
"avg_response_time_ms": 150.5,
|
||||
"goroutines": 42,
|
||||
"memory_alloc_mb": 45.6
|
||||
}
|
||||
```
|
||||
|
||||
### 性能分析(pprof)
|
||||
|
||||
```bash
|
||||
# CPU性能分析(采集30秒)
|
||||
curl http://localhost:8080/debug/pprof/profile?seconds=30 -o cpu.prof
|
||||
|
||||
# 内存分析
|
||||
curl http://localhost:8080/debug/pprof/heap -o heap.prof
|
||||
|
||||
# Goroutine分析
|
||||
curl http://localhost:8080/debug/pprof/goroutine -o goroutine.prof
|
||||
|
||||
# 查看分析结果
|
||||
go tool pprof cpu.prof
|
||||
```
|
||||
|
||||
## 性能配置
|
||||
|
||||
### 限流
|
||||
|
||||
系统默认配置:
|
||||
- **桶容量**: 200(突发请求)
|
||||
- **补充速率**: 50/秒(持续QPS)
|
||||
|
||||
超过限流会返回 `429 Too Many Requests`。
|
||||
|
||||
### 连接池
|
||||
|
||||
- LLM API: 100最大连接,20/host
|
||||
- 其他API: 50最大连接,10/host
|
||||
|
||||
### 超时配置
|
||||
|
||||
- 读取请求: 30秒
|
||||
- 写入响应: 300秒(流式响应)
|
||||
- LLM请求: 120秒
|
||||
- 其他API: 10-60秒
|
||||
|
||||
## Docker部署
|
||||
|
||||
### 使用docker-compose(推荐)
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 手动构建
|
||||
|
||||
```bash
|
||||
docker build -t qd-sc-server .
|
||||
docker run -d -p 8080:8080 \
|
||||
-e LLM_API_KEY="sk-xxx" \
|
||||
-e LLM_BASE_URL="https://your-api.com/v1" \
|
||||
-e AMAP_API_KEY="xxx" \
|
||||
-e OCR_BASE_URL="https://your-ocr-api.example.com" \
|
||||
--name qd-sc-server \
|
||||
qd-sc-server
|
||||
```
|
||||
|
||||
### 多架构镜像(amd64/arm64)
|
||||
|
||||
> 说明:仓库内 `Dockerfile` 已支持 buildx 的 `TARGETARCH/TARGETOS`,可直接构建 `linux/amd64` + `linux/arm64` 的同 tag 多架构镜像(manifest)。
|
||||
|
||||
```bash
|
||||
# 一次推送多架构(推荐)
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t t0ng7u/qd-sc:latest --push .
|
||||
|
||||
# 或仅推送 arm64(可选)
|
||||
docker buildx build --platform linux/arm64 -t t0ng7u/qd-sc:arm64 --push .
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
docker logs -f qd-sc-server
|
||||
```
|
||||
165
cmd/server/main.go
Normal file
165
cmd/server/main.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"qd-sc/internal/api/handler"
|
||||
"qd-sc/internal/api/middleware"
|
||||
"qd-sc/internal/client"
|
||||
"qd-sc/internal/config"
|
||||
"qd-sc/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
serverWg sync.WaitGroup
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "配置文件路径")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
if _, err2 := os.Stat("config.local.yaml"); err2 == nil {
|
||||
cfg, err = config.Load("config.local.yaml")
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
log.Printf("性能配置: GOMAXPROCS=%d, MaxGoroutines=%d, PoolSize=%d, QueueSize=%d",
|
||||
runtime.NumCPU(),
|
||||
cfg.Performance.MaxGoroutines,
|
||||
cfg.Performance.GoroutinePoolSize,
|
||||
cfg.Performance.TaskQueueSize)
|
||||
|
||||
llmClient := client.NewLLMClient(cfg)
|
||||
amapClient := client.NewAmapClient(cfg)
|
||||
jobClient := client.NewJobClient(cfg)
|
||||
ocrClient := client.NewOCRClient(cfg)
|
||||
|
||||
locationService := service.NewLocationService(cfg, amapClient)
|
||||
jobService := service.NewJobService(cfg, jobClient)
|
||||
policyService := service.NewPolicyService(cfg)
|
||||
chatService := service.NewChatService(cfg, llmClient, ocrClient, locationService, jobService, policyService)
|
||||
|
||||
chatHandler := handler.NewChatHandler(chatService)
|
||||
healthHandler := handler.NewHealthHandler()
|
||||
metricsHandler := handler.NewMetricsHandler()
|
||||
|
||||
if cfg.Logging.Level == "debug" {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
} else {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
|
||||
router.Use(middleware.Recovery())
|
||||
router.Use(gin.Logger())
|
||||
router.Use(middleware.CORS())
|
||||
router.Use(middleware.RateLimit(200, 50))
|
||||
if cfg.Performance.EnableMetrics == nil || *cfg.Performance.EnableMetrics {
|
||||
router.Use(middleware.Metrics())
|
||||
}
|
||||
|
||||
router.GET("/health", healthHandler.Check)
|
||||
if cfg.Performance.EnableMetrics == nil || *cfg.Performance.EnableMetrics {
|
||||
router.GET("/metrics", metricsHandler.GetMetrics)
|
||||
}
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"message": cfg.City.SystemName + " API",
|
||||
"version": "1.0.0",
|
||||
"endpoints": []string{
|
||||
"POST /v1/chat/completions",
|
||||
"GET /health",
|
||||
"GET /metrics (性能指标)",
|
||||
"GET /debug/pprof/* (性能分析)",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if cfg.Performance.EnablePprof == nil || *cfg.Performance.EnablePprof {
|
||||
debugGroup := router.Group("/debug/pprof")
|
||||
{
|
||||
debugGroup.GET("/", gin.WrapF(http.DefaultServeMux.ServeHTTP))
|
||||
debugGroup.GET("/:name", gin.WrapF(http.DefaultServeMux.ServeHTTP))
|
||||
debugGroup.POST("/:name", gin.WrapF(http.DefaultServeMux.ServeHTTP))
|
||||
}
|
||||
}
|
||||
|
||||
v1 := router.Group("/v1")
|
||||
{
|
||||
v1.POST("/chat/completions", chatHandler.ChatCompletions)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20, // 1MB
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
serverWg.Add(1)
|
||||
go func() {
|
||||
defer serverWg.Done()
|
||||
log.Printf("服务器启动在 %s", addr)
|
||||
log.Printf("OpenAI兼容端点: POST http://%s/v1/chat/completions", addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("启动服务器失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("正在关闭服务器...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Printf("服务器强制关闭: %v", err)
|
||||
} else {
|
||||
log.Println("HTTP服务器已优雅关闭")
|
||||
}
|
||||
|
||||
log.Println("清理资源完成")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
serverWg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.Println("所有goroutine已完成")
|
||||
case <-time.After(5 * time.Second):
|
||||
log.Println("等待超时,强制退出")
|
||||
}
|
||||
|
||||
log.Println("服务器已完全关闭")
|
||||
}
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
qd-sc-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: t0ng7u/qd-sc:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
environment:
|
||||
- GIN_MODE=release
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
34
go.mod
Normal file
34
go.mod
Normal file
@@ -0,0 +1,34 @@
|
||||
module qd-sc
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.9.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
)
|
||||
86
go.sum
Normal file
86
go.sum
Normal file
@@ -0,0 +1,86 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
157
internal/api/handler/chat.go
Normal file
157
internal/api/handler/chat.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
23
internal/api/handler/health.go
Normal file
23
internal/api/handler/health.go
Normal 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",
|
||||
})
|
||||
}
|
||||
26
internal/api/handler/metrics.go
Normal file
26
internal/api/handler/metrics.go
Normal 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)
|
||||
}
|
||||
33
internal/api/handler/response.go
Normal file
33
internal/api/handler/response.go
Normal 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()
|
||||
33
internal/api/middleware/cors.go
Normal file
33
internal/api/middleware/cors.go
Normal 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()
|
||||
}
|
||||
}
|
||||
53
internal/api/middleware/cors_test.go
Normal file
53
internal/api/middleware/cors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
41
internal/api/middleware/metrics.go
Normal file
41
internal/api/middleware/metrics.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
103
internal/api/middleware/ratelimit.go
Normal file
103
internal/api/middleware/ratelimit.go
Normal 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()
|
||||
}
|
||||
}
|
||||
35
internal/api/middleware/ratelimit_test.go
Normal file
35
internal/api/middleware/ratelimit_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
35
internal/api/middleware/recovery.go
Normal file
35
internal/api/middleware/recovery.go
Normal 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()
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
252
internal/config/config.go
Normal file
252
internal/config/config.go
Normal 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
107
internal/model/job.go
Normal 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
121
internal/model/openai.go
Normal 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
59
internal/model/policy.go
Normal 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
336
internal/model/tool.go
Normal 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)
|
||||
}
|
||||
1042
internal/service/chat_service.go
Normal file
1042
internal/service/chat_service.go
Normal file
File diff suppressed because it is too large
Load Diff
131
internal/service/job_service.go
Normal file
131
internal/service/job_service.go
Normal 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)
|
||||
}
|
||||
30
internal/service/location_service.go
Normal file
30
internal/service/location_service.go
Normal 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
|
||||
}
|
||||
58
internal/service/policy_service.go
Normal file
58
internal/service/policy_service.go
Normal 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
|
||||
}
|
||||
162
pkg/metrics/metrics.go
Normal file
162
pkg/metrics/metrics.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metrics 性能指标收集器
|
||||
type Metrics struct {
|
||||
// 请求计数
|
||||
totalRequests uint64
|
||||
activeRequests int64
|
||||
failedRequests uint64
|
||||
streamRequests uint64
|
||||
|
||||
// 延迟统计
|
||||
requestLatency sync.Map // map[string]*LatencyStats
|
||||
|
||||
// 系统指标
|
||||
startTime time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// LatencyStats 延迟统计
|
||||
type LatencyStats struct {
|
||||
count uint64
|
||||
sum uint64
|
||||
min uint64
|
||||
max uint64
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var globalMetrics = &Metrics{
|
||||
startTime: time.Now(),
|
||||
}
|
||||
|
||||
// GetGlobalMetrics 获取全局指标收集器
|
||||
func GetGlobalMetrics() *Metrics {
|
||||
return globalMetrics
|
||||
}
|
||||
|
||||
// IncTotalRequests 增加总请求数
|
||||
func (m *Metrics) IncTotalRequests() {
|
||||
atomic.AddUint64(&m.totalRequests, 1)
|
||||
}
|
||||
|
||||
// IncActiveRequests 增加活跃请求数
|
||||
func (m *Metrics) IncActiveRequests() {
|
||||
atomic.AddInt64(&m.activeRequests, 1)
|
||||
}
|
||||
|
||||
// DecActiveRequests 减少活跃请求数
|
||||
func (m *Metrics) DecActiveRequests() {
|
||||
atomic.AddInt64(&m.activeRequests, -1)
|
||||
}
|
||||
|
||||
// IncFailedRequests 增加失败请求数
|
||||
func (m *Metrics) IncFailedRequests() {
|
||||
atomic.AddUint64(&m.failedRequests, 1)
|
||||
}
|
||||
|
||||
// IncStreamRequests 增加流式请求数
|
||||
func (m *Metrics) IncStreamRequests() {
|
||||
atomic.AddUint64(&m.streamRequests, 1)
|
||||
}
|
||||
|
||||
// RecordLatency 记录请求延迟
|
||||
func (m *Metrics) RecordLatency(endpoint string, duration time.Duration) {
|
||||
durationMs := uint64(duration.Milliseconds())
|
||||
|
||||
val, _ := m.requestLatency.LoadOrStore(endpoint, &LatencyStats{
|
||||
min: durationMs,
|
||||
max: durationMs,
|
||||
})
|
||||
|
||||
stats := val.(*LatencyStats)
|
||||
stats.mu.Lock()
|
||||
defer stats.mu.Unlock()
|
||||
|
||||
stats.count++
|
||||
stats.sum += durationMs
|
||||
if durationMs < stats.min {
|
||||
stats.min = durationMs
|
||||
}
|
||||
if durationMs > stats.max {
|
||||
stats.max = durationMs
|
||||
}
|
||||
}
|
||||
|
||||
// GetStats 获取统计信息
|
||||
func (m *Metrics) GetStats() map[string]interface{} {
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
totalReq := atomic.LoadUint64(&m.totalRequests)
|
||||
activeReq := atomic.LoadInt64(&m.activeRequests)
|
||||
failedReq := atomic.LoadUint64(&m.failedRequests)
|
||||
streamReq := atomic.LoadUint64(&m.streamRequests)
|
||||
|
||||
uptime := time.Since(m.startTime)
|
||||
qps := float64(totalReq) / uptime.Seconds()
|
||||
|
||||
latencyStats := make(map[string]interface{})
|
||||
m.requestLatency.Range(func(key, value interface{}) bool {
|
||||
endpoint := key.(string)
|
||||
stats := value.(*LatencyStats)
|
||||
|
||||
stats.mu.RLock()
|
||||
defer stats.mu.RUnlock()
|
||||
|
||||
avg := uint64(0)
|
||||
if stats.count > 0 {
|
||||
avg = stats.sum / stats.count
|
||||
}
|
||||
|
||||
latencyStats[endpoint] = map[string]interface{}{
|
||||
"count": stats.count,
|
||||
"avg_ms": avg,
|
||||
"min_ms": stats.min,
|
||||
"max_ms": stats.max,
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return map[string]interface{}{
|
||||
"requests": map[string]interface{}{
|
||||
"total": totalReq,
|
||||
"active": activeReq,
|
||||
"failed": failedReq,
|
||||
"stream": streamReq,
|
||||
"success": totalReq - failedReq,
|
||||
},
|
||||
"performance": map[string]interface{}{
|
||||
"qps": qps,
|
||||
"uptime": uptime.String(),
|
||||
"latency": latencyStats,
|
||||
},
|
||||
"system": map[string]interface{}{
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"cpu_cores": runtime.NumCPU(),
|
||||
"gomaxprocs": runtime.GOMAXPROCS(0),
|
||||
"memory_alloc_mb": memStats.Alloc / 1024 / 1024,
|
||||
"memory_sys_mb": memStats.Sys / 1024 / 1024,
|
||||
"memory_heap_mb": memStats.HeapAlloc / 1024 / 1024,
|
||||
"gc_count": memStats.NumGC,
|
||||
"gc_pause_total": time.Duration(memStats.PauseTotalNs).String(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 重置统计信息(用于测试)
|
||||
func (m *Metrics) Reset() {
|
||||
atomic.StoreUint64(&m.totalRequests, 0)
|
||||
atomic.StoreInt64(&m.activeRequests, 0)
|
||||
atomic.StoreUint64(&m.failedRequests, 0)
|
||||
atomic.StoreUint64(&m.streamRequests, 0)
|
||||
m.requestLatency = sync.Map{}
|
||||
m.startTime = time.Now()
|
||||
}
|
||||
15
pkg/utils/json.go
Normal file
15
pkg/utils/json.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ToJSONStringPretty 将对象转换为格式化的JSON字符串
|
||||
func ToJSONStringPretty(v interface{}) (string, error) {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("JSON序列化失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
Reference in New Issue
Block a user