commit f07062dbd74399c4ed4d631ec3687468b3afadf8 Author: 李顺东 <577732344@qq.com> Date: Mon Jan 12 11:33:43 2026 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc43194 --- /dev/null +++ b/.dockerignore @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd8c54b --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..67db3ac --- /dev/null +++ b/API_DOCS.md @@ -0,0 +1,1755 @@ +# 青岛岗位匹配系统 API 调用文档 + +> **版本**: 1.0.0 +> **更新日期**: 2025-11 +> **协议**: 兼容 OpenAI Chat Completions API + +--- + +## 目录 + +- [1. 系统概述](#1-系统概述) +- [2. 系统架构](#2-系统架构) +- [3. 快速开始](#3-快速开始) +- [4. API 端点列表](#4-api-端点列表) +- [5. 核心接口详解](#5-核心接口详解) + - [5.1 聊天补全接口](#51-聊天补全接口) + - [5.2 健康检查接口](#52-健康检查接口) + - [5.3 性能指标接口](#53-性能指标接口) + - [5.4 性能分析接口](#54-性能分析接口) +- [6. 内置工具说明](#6-内置工具说明) +- [7. 代码对照表](#7-代码对照表) +- [8. SDK 与代码示例](#8-sdk-与代码示例) +- [9. 错误处理](#9-错误处理) +- [10. 配置参考](#10-配置参考) +- [11. 部署指南](#11-部署指南) +- [12. 常见问题](#12-常见问题) + +--- + +## 1. 系统概述 + +### 1.1 系统简介 + +青岛岗位匹配系统是一个基于 Go 语言开发的智能岗位推荐服务,提供与 OpenAI `/v1/chat/completions` 完全兼容的 API 接口。系统集成了多种智能工具,能够自动理解用户意图并调用相应功能。 + +### 1.2 核心功能 + +| 功能模块 | 说明 | +|---------|------| +| 🎯 **岗位推荐** | 根据用户需求、简历内容智能推荐青岛市岗位 | +| 📍 **地理位置查询** | 集成高德地图,支持按位置、区域搜索岗位 | +| 📄 **文件解析** | 支持 PDF、图片、Excel、PPT、Word 等文件的 OCR 智能解析 | +| 🖼️ **Vision API** | 兼容 OpenAI Vision API 格式,支持通过 URL 发送图片/PDF/Excel/PPT 等文件进行 OCR 识别 | +| 📋 **政策咨询** | 提供就业创业、社保医保、人才政策等咨询服务 | +| 🔄 **多轮对话** | 支持上下文连续对话 | +| ⚡ **流式输出** | 支持 SSE 流式响应,提升用户体验 | + +### 1.3 技术特性 + +- **兼容性**: 100% 兼容 OpenAI Chat API 格式 +- **高性能**: 基于 Gin 框架,支持高并发 +- **限流保护**: 内置令牌桶限流(200 容量/50 QPS) +- **优雅关闭**: 支持信号处理和优雅停机 +- **可观测性**: 内置 JSON 指标端点和 pprof 性能分析(可通过配置开关关闭) + +--- + +## 2. 系统架构 + +### 2.1 架构图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户端 (Client) │ +│ (Web/App/第三方 OpenAI 兼容客户端) │ +└─────────────────────────────┬───────────────────────────────────┘ + │ HTTP/HTTPS + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ API 网关层 (Gateway) │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │ +│ │ CORS │ │ Recovery│ │ Metrics │ │ Rate │ │ Logger │ │ +│ │Middleware│ │Middleware│ │Middleware│ │ Limit │ │ │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │ +└─────────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 处理器层 (Handlers) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ ChatHandler │ │HealthHandler │ │MetricsHandler│ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 服务层 (Services) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ ChatService │ │ JobService │ │FileService │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │LocationServ │ │PolicyService│ │ +│ └─────────────┘ └─────────────┘ │ +└─────────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 客户端层 (Clients) │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ LLMClient │ │ JobClient │ │AmapClient │ │ OCRClient │ │ +│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ │ +└────────┼─────────────┼─────────────┼─────────────┼──────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ +│ LLM API │ │ 岗位 API │ │ 高德地图 │ │ OCR 服务 │ +│ (OpenAI兼容) │ │ │ │ API │ │ │ +└──────────────┘ └──────────────┘ └──────────┘ └──────────────┘ +``` + +### 2.2 数据流 + +``` +用户请求 → 中间件处理 → ChatHandler → ChatService + ↓ + 识别意图 & 调用工具 + ↓ + ┌────────────────┼────────────────┐ + ↓ ↓ ↓ + LocationService JobService PolicyService + ↓ ↓ ↓ + 高德地图API 岗位API 政策API + ↓ ↓ ↓ + └────────────────┼────────────────┘ + ↓ + 整合结果 & 生成回复 + ↓ + 流式/非流式响应 → 用户 +``` + +--- + +## 3. 快速开始 + +### 3.1 环境要求 + +- Go 1.20+ +- 或 Docker 20.10+ + +### 3.2 安装运行 + +#### 方式一:源码运行 + +```bash +# 克隆项目 +git clone +cd qd-sc + +# 安装依赖 +go mod download + +# 配置文件(编辑 config.yaml) +cp config.yaml config.local.yaml +vim config.local.yaml + +# 运行 +go run cmd/server/main.go + +# 或编译后运行 +go build -o qd-sc-server cmd/server/main.go +./qd-sc-server -config=config.yaml +``` + +#### 方式二:Docker 运行 + +```bash +# 使用 docker-compose +docker-compose up -d + +# 或手动构建 +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" \ + --name qd-sc-server \ + qd-sc-server +``` + +### 3.3 验证服务 + +```bash +# 健康检查 +curl http://localhost:8080/health + +# 测试对话(流式) +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 + }' +``` + +--- + +## 4. API 端点列表 + +| 端点 | 方法 | 说明 | 认证 | +|------|------|------|------| +| `/` | GET | API 信息和端点列表 | 无 | +| `/health` | GET | 健康检查 | 无 | +| `/metrics` | GET | 性能指标(JSON,需启用 `performance.enable_metrics`) | 无 | +| `/v1/chat/completions` | POST | **核心接口** - OpenAI 兼容的聊天接口 | 无 | +| `/debug/pprof/*` | GET | pprof 性能分析(需启用 `performance.enable_pprof`) | 无 | + +--- + +## 5. 核心接口详解 + +### 5.1 聊天补全接口 + +#### 基本信息 + +| 属性 | 值 | +|------|-----| +| **端点** | `POST /v1/chat/completions` | +| **Content-Type** | `application/json` | +| **响应格式** | JSON 或 SSE (Server-Sent Events) | + +#### 5.1.1 JSON 请求格式 + +```http +POST /v1/chat/completions HTTP/1.1 +Host: localhost:8080 +Content-Type: application/json + +{ + "model": "qd-job-turbo", + "messages": [ + { + "role": "system", + "content": "你是一个有帮助的助手" + }, + { + "role": "user", + "content": "帮我找青岛城阳区的Java开发岗位" + } + ], + "stream": true, + "temperature": 0.7, + "max_tokens": 2000 +} +``` + +#### 5.1.2 请求参数说明 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `model` | string | ✅ | - | 模型名称,固定为 `qd-job-turbo` | +| `messages` | array | ✅ | - | 消息数组,见下表 | +| `stream` | boolean | ❌ | `false` | 是否流式输出(**推荐 `true`**) | +| `temperature` | float | ❌ | `1.0` | 采样温度,范围 0-2 | +| `top_p` | float | ❌ | `1.0` | 核采样参数 | +| `max_tokens` | integer | ❌ | - | 最大生成 token 数 | +| `presence_penalty` | float | ❌ | `0.0` | 存在惩罚,范围 -2.0 到 2.0 | +| `frequency_penalty` | float | ❌ | `0.0` | 频率惩罚,范围 -2.0 到 2.0 | +| `user` | string | ❌ | - | 用户标识 | + +#### 5.1.3 消息对象格式 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `role` | string | ✅ | 角色:`system`、`user`、`assistant` | +| `content` | string/array | ✅ | 消息内容(支持文本或多模态数组) | +| `name` | string | ❌ | 发送者名称 | + +**role 说明**: + +| 角色 | 说明 | +|------|------| +| `system` | 系统指令,设置 AI 行为 | +| `user` | 用户消息 | +| `assistant` | AI 回复 | + +**content 格式说明**: + +`content` 支持两种格式: + +1. **字符串格式**(普通文本消息): +```json +{ + "role": "user", + "content": "帮我推荐Java开发岗位" +} +``` + +2. **数组格式**(多模态消息,支持图片 - OpenAI Vision API 兼容): +```json +{ + "role": "user", + "content": [ + {"type": "text", "text": "根据这份简历帮我推荐合适的岗位"}, + {"type": "image_url", "image_url": {"url": "https://example.com/resume.jpg"}} + ] +} +``` + +**多模态内容类型**: + +| type | 说明 | 字段 | +|------|------|------| +| `text` | 文本内容 | `text`: 文本字符串 | +| `image_url` | 文件URL(兼容字段) | `image_url.url`: 文件地址 | + +> **重要说明**: +> - `image_url` 是为兼容 OpenAI Vision API 而保留的字段名称 +> - 实际上不仅支持图片,**还支持 PDF、Excel、PPT 等所有 OCR 服务支持的文件类型** +> - 系统会自动调用 OCR 服务进行识别解析 +> - 支持的文件格式:JPG、PNG、GIF、PDF、XLS、XLSX、PPT、PPTX 等 + +#### 5.1.4 带文件URL的请求(Vision API 兼容格式) + +完全兼容 OpenAI Vision API 格式,通过 `image_url` 字段发送文件 URL 进行 OCR 识别。 + +> **字段说明**: `image_url` 是 OpenAI Vision API 的标准字段名,为保持兼容性沿用此名称。但本系统通过 OCR 服务,**不仅支持图片,还支持 PDF、Excel、PPT 等多种文件格式**。 + +**示例 1:发送图片简历** + +```http +POST /v1/chat/completions HTTP/1.1 +Host: localhost:8080 +Content-Type: application/json + +{ + "model": "qd-job-turbo", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "根据这份简历帮我推荐合适的岗位"}, + {"type": "image_url", "image_url": {"url": "https://example.com/resume.jpg"}} + ] + } + ], + "stream": true +} +``` + +**示例 2:发送 PDF 文件** + +```json +{ + "role": "user", + "content": [ + {"type": "text", "text": "分析这份PDF简历"}, + {"type": "image_url", "image_url": {"url": "https://example.com/resume.pdf"}} + ] +} +``` + +**示例 3:发送 Excel 文件** + +```json +{ + "role": "user", + "content": [ + {"type": "text", "text": "帮我分析这个表格数据"}, + {"type": "image_url", "image_url": {"url": "https://example.com/data.xlsx"}} + ] +} +``` + +**示例 4:发送多个文件** + +```json +{ + "role": "user", + "content": [ + {"type": "text", "text": "分析这些文档内容"}, + {"type": "image_url", "image_url": {"url": "https://example.com/resume.pdf"}}, + {"type": "image_url", "image_url": {"url": "https://example.com/certificate.jpg"}}, + {"type": "image_url", "image_url": {"url": "https://example.com/transcript.xlsx"}} + ] +} +``` + +**支持的文件格式**: + +| 类型 | 格式 | +|------|------| +| 图片 | JPG、JPEG、PNG、GIF | +| 文档 | PDF | +| 表格 | XLS、XLSX | +| 演示 | PPT、PPTX | + +#### 5.1.6 流式响应格式 (SSE) + +当 `stream: true` 时,响应为 Server-Sent Events 格式: + +``` +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: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qd-job-turbo","choices":[{"index":0,"delta":{"content":"我"},"finish_reason":null}]} + +data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qd-job-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: [DONE] +``` + +**Chunk 对象结构**: + +```typescript +interface ChatCompletionChunk { + id: string; // 响应ID + object: "chat.completion.chunk"; + created: number; // Unix 时间戳 + model: string; // 模型名称 + choices: Array<{ + index: number; + delta: { + role?: "assistant"; // 仅首个 chunk 包含 + content?: string; // 内容片段 + }; + finish_reason: string | null; // "stop" 或 null + }>; +} +``` + +#### 5.1.7 非流式响应格式 + +当 `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 + } +} +``` + +#### 5.1.8 岗位推荐特殊响应格式 + +当系统返回岗位推荐时,岗位信息会以特殊的 Markdown 代码块格式输出: + +``` +为您找到 3 个相关岗位: + +``` job-json +{ + "jobTitle": "Java开发工程师", + "companyName": "青岛XX科技有限公司", + "salary": "15000-25000元/月", + "location": "城阳区", + "education": "本科", + "experience": "3-5年", + "appJobUrl": "https://..." +} +``` + +``` job-json +{ + "jobTitle": "高级Java工程师", + "companyName": "青岛YY信息技术有限公司", + "salary": "20000-35000元/月", + "location": "城阳区", + "education": "本科", + "experience": "5-10年", + "appJobUrl": "https://..." +} +``` +``` + +**岗位对象结构**: + +```typescript +interface FormattedJob { + jobTitle: string; // 职位名称 + companyName: string; // 公司名称 + salary: string; // 薪资范围 + location: string; // 工作地点 + education: string; // 学历要求 + experience: string; // 经验要求 + appJobUrl: string; // 职位详情链接 + data?: any; // 额外数据(分页信息等) +} +``` + +--- + +### 5.2 健康检查接口 + +#### 请求 + +```http +GET /health HTTP/1.1 +Host: localhost:8080 +``` + +#### 响应 + +```json +{ + "status": "ok", + "timestamp": "2024-01-01T12:00:00Z" +} +``` + +--- + +### 5.3 性能指标接口 + +> 该接口需要启用 `performance.enable_metrics`;关闭后将不会注册 `/metrics` 端点。 + +#### 请求 + +```http +GET /metrics HTTP/1.1 +Host: localhost:8080 +``` + +#### 响应 + +```json +{ + "requests_total": 12345, + "requests_success": 12300, + "requests_failed": 45, + "avg_response_time_ms": 156.8, + "goroutines": 42, + "memory_alloc_mb": 45.6 +} +``` + +| 指标 | 说明 | +|------|------| +| `requests_total` | 总请求数 | +| `requests_success` | 成功请求数 | +| `requests_failed` | 失败请求数 | +| `avg_response_time_ms` | 平均响应时间(毫秒) | +| `goroutines` | 当前 goroutine 数量 | +| `memory_alloc_mb` | 内存分配(MB) | + +--- + +### 5.4 性能分析接口 + +> 该接口需要启用 `performance.enable_pprof`;关闭后将不会注册 `/debug/pprof/*` 端点。 + +支持 Go pprof 标准端点: + +| 端点 | 说明 | +|------|------| +| `/debug/pprof/` | pprof 索引页面 | +| `/debug/pprof/profile?seconds=30` | CPU 性能分析 | +| `/debug/pprof/heap` | 堆内存分析 | +| `/debug/pprof/goroutine` | Goroutine 分析 | +| `/debug/pprof/allocs` | 内存分配分析 | + +**使用示例**: + +```bash +# 采集 30 秒 CPU 数据 +curl http://localhost:8080/debug/pprof/profile?seconds=30 -o cpu.prof + +# 分析 +go tool pprof cpu.prof +``` + +--- + +## 6. 内置工具说明 + +系统内置了多种智能工具,会根据用户意图自动调用: + +### 6.1 queryLocation - 地理位置查询 + +**功能**: 查询青岛具体地点的经纬度坐标 + +**触发场景**: 用户提到具体地点名称时(如"五四广场附近"、"青岛啤酒博物馆周边") + +**参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `keywords` | string | ✅ | 地点名称,如"五四广场" | + +**返回示例**: + +```json +{ + "keywords": "五四广场", + "latitude": "36.061892", + "longitude": "120.384428", + "message": "成功获取地点 五四广场 的坐标" +} +``` + +--- + +### 6.2 queryJobsByArea - 按区域查询岗位 + +**功能**: 根据青岛市区域代码查询岗位信息 + +**触发场景**: 用户指定区域名称时(如"城阳区"、"市北区") + +**参数**: + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `jobTitle` | string | ✅ | - | 岗位关键词 | +| `current` | integer | ✅ | 1 | 页码 | +| `pageSize` | integer | ✅ | 10 | 每页数量 | +| `jobLocationAreaCode` | string | ❌ | - | 区域代码(见代码表) | +| `order` | string | ❌ | "0" | 排序:0-推荐,1-最热,2-最新 | +| `minSalary` | string | ❌ | - | 最低薪资(元/月) | +| `maxSalary` | string | ❌ | - | 最高薪资(元/月) | +| `experience` | string | ❌ | - | 经验要求代码 | +| `education` | string | ❌ | - | 学历要求代码 | +| `companyNature` | string | ❌ | - | 企业类型代码 | + +--- + +### 6.3 queryJobsByLocation - 按坐标查询岗位 + +**功能**: 根据经纬度和半径查询附近岗位 + +**触发场景**: 用户指定具体地点后,需要查询附近岗位 + +**参数**: + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `jobTitle` | string | ✅ | - | 岗位关键词 | +| `current` | integer | ✅ | 1 | 页码 | +| `pageSize` | integer | ✅ | 10 | 每页数量 | +| `latitude` | string | ✅ | - | 纬度 | +| `longitude` | string | ✅ | - | 经度 | +| `radius` | string | ✅ | "10" | 搜索半径(千米,最大50) | +| `order` | string | ❌ | "0" | 排序方式 | +| `minSalary` | string | ❌ | - | 最低薪资 | +| `maxSalary` | string | ❌ | - | 最高薪资 | +| `experience` | string | ❌ | - | 经验要求 | +| `education` | string | ❌ | - | 学历要求 | +| `companyNature` | string | ❌ | - | 企业类型 | + +--- + +### 6.4 queryPolicy - 政策咨询 + +**功能**: 查询青岛市就业创业、社保医保、人才政策等 + +**触发场景**: 用户咨询政策相关问题 + +**参数**: + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `message` | string | ✅ | - | 咨询问题 | +| `chatId` | string | ❌ | - | 会话ID(多轮对话) | +| `conversationId` | string | ❌ | - | 流水号(多轮对话) | +| `realName` | boolean | ❌ | false | 是否实名咨询 | +| `aac001` | string | ❌* | - | 个人编号(实名时必填) | +| `aac147` | string | ❌* | - | 身份证号(实名时必填) | +| `aac003` | string | ❌* | - | 姓名(实名时必填) | + +--- + +### 6.5 parsePDF - PDF 解析 + +**功能**: 使用 OCR 服务解析 PDF 文件内容(如简历) + +**说明**: 通常在文件上传时自动触发,无需手动调用。文件会通过 OCR 服务进行识别解析。 + +**参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `fileUrl` | string | ✅ | PDF 文件 URL | + +--- + +### 6.6 parseImage - 图片解析 + +**功能**: 使用 OCR 服务识别图片中的文本内容 + +**说明**: 通常在文件上传时自动触发,无需手动调用。文件会通过 OCR 服务进行识别解析。 + +**参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `imageUrl` | string | ✅ | 图片文件 URL | + +--- + +## 7. 代码对照表 + +### 7.1 区域代码 (jobLocationAreaCode) + +| 代码 | 区域 | +|------|------| +| 0 | 市南区 | +| 1 | 市北区 | +| 2 | 李沧区 | +| 3 | 崂山区 | +| 4 | 黄岛区 | +| 5 | 城阳区 | +| 6 | 即墨区 | +| 7 | 胶州市 | +| 8 | 平度市 | +| 9 | 莱西市 | + +### 7.2 学历代码 (education) + +| 代码 | 学历 | +|------|------| +| -1 | 学历不限 | +| 0 | 初中及以下 | +| 1 | 中专/中技 | +| 2 | 高中 | +| 3 | 大专 | +| 4 | 本科 | +| 5 | 硕士 | +| 6 | 博士 | +| 7 | MBA/EMBA | +| 8 | 留学-学士 | +| 9 | 留学-硕士 | +| 10 | 留学-博士 | + +### 7.3 经验代码 (experience) + +| 代码 | 经验 | +|------|------| +| 0 | 经验不限 | +| 1 | 实习生 | +| 2 | 应届毕业生 | +| 3 | 1年以下 | +| 4 | 1-3年 | +| 5 | 3-5年 | +| 6 | 5-10年 | +| 7 | 10年以上 | + +### 7.4 企业类型代码 (companyNature) + +| 代码 | 类型 | +|------|------| +| 1 | 私营企业 | +| 2 | 股份制企业 | +| 3 | 国有企业 | +| 4 | 外商及港澳台投资企业 | +| 5 | 医院 | + +### 7.5 排序方式代码 (order) + +| 代码 | 排序 | +|------|------| +| 0 | 推荐 | +| 1 | 最热 | +| 2 | 最新发布 | + +--- + +## 8. SDK 与代码示例 + +### 8.1 cURL 示例 + +#### 基础对话 + +```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 + }' +``` + +#### 带文件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.jpg"}} + ] + } + ], + "stream": true + }' + +# 发送 PDF 文件 +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": "分析这份PDF简历"}, + {"type": "image_url", "image_url": {"url": "https://example.com/resume.pdf"}} + ] + } + ], + "stream": true + }' +``` + +#### 多轮对话 + +```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": "城阳区,要求薪资15k以上"} + ], + "stream": true + }' +``` + +#### 政策咨询 + +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qd-job-turbo", + "messages": [ + {"role": "user", "content": "青岛市大学生就业补贴政策是怎样的?"} + ], + "stream": true + }' +``` + +--- + +### 8.2 Python 示例 + +#### 使用 OpenAI SDK + +```python +from openai import OpenAI + +# 创建客户端,指向本地服务 +client = OpenAI( + base_url="http://localhost:8080/v1", + api_key="not-needed" # 本系统不需要 API key +) + +# 流式对话 +def chat_stream(message: str): + stream = client.chat.completions.create( + model="qd-job-turbo", + messages=[{"role": "user", "content": message}], + stream=True + ) + + for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) + print() + +# 示例 +chat_stream("帮我推荐青岛城阳区的Java开发岗位") +``` + +#### 使用 Vision API 兼容格式发送文件 URL + +通过 `image_url` 字段发送文件 URL,支持图片、PDF、Excel、PPT 等格式: + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:8080/v1", + api_key="not-needed" +) + +# 带文件URL的对话(Vision API 兼容格式) +# 注意:image_url 是兼容字段名,实际支持多种文件格式 +def chat_with_file_url(text: str, file_url: str): + stream = client.chat.completions.create( + model="qd-job-turbo", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": text}, + {"type": "image_url", "image_url": {"url": file_url}} + ] + } + ], + stream=True + ) + + for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) + print() + +# 示例1:发送图片简历 +chat_with_file_url( + "根据这份简历帮我推荐合适的岗位", + "https://example.com/resume.jpg" +) + +# 示例2:发送 PDF 文件 +chat_with_file_url( + "分析这份PDF文档", + "https://example.com/document.pdf" +) + +# 示例3:发送 Excel 文件 +chat_with_file_url( + "帮我分析这个表格", + "https://example.com/data.xlsx" +) +``` + +#### 使用 Requests 库 + +```python +import requests +import json + +def chat_with_file(message: str, file_path: str = None): + url = "http://localhost:8080/v1/chat/completions" + + if file_path: + # 带文件上传 + files = { + 'file': open(file_path, 'rb'), + 'request': (None, json.dumps({ + "model": "qd-job-turbo", + "messages": [{"role": "user", "content": message}], + "stream": True + })) + } + response = requests.post(url, files=files, stream=True) + else: + # 普通请求 + response = requests.post( + url, + json={ + "model": "qd-job-turbo", + "messages": [{"role": "user", "content": message}], + "stream": True + }, + stream=True + ) + + # 处理流式响应 + for line in response.iter_lines(): + if line: + line = line.decode('utf-8') + if line.startswith('data: '): + data = line[6:] + if data == '[DONE]': + break + chunk = json.loads(data) + if chunk['choices'][0]['delta'].get('content'): + print(chunk['choices'][0]['delta']['content'], end='', flush=True) + print() + +# 示例 +chat_with_file("根据我的简历推荐岗位", "resume.pdf") +``` + +--- + +### 8.3 JavaScript/TypeScript 示例 + +#### 使用 Fetch API + +```typescript +interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +async function chat(messages: ChatMessage[]): Promise { + const response = await fetch('http://localhost:8080/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'qd-job-turbo', + messages, + stream: true, + }), + }); + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + if (!reader) return; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + const lines = text.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') return; + + try { + const chunk = JSON.parse(data); + const content = chunk.choices[0]?.delta?.content; + if (content) { + process.stdout.write(content); + } + } catch (e) { + // 忽略解析错误 + } + } + } + } +} + +// 使用示例 +chat([ + { role: 'user', content: '帮我推荐城阳区的Java开发岗位' } +]); +``` + +#### 使用 OpenAI SDK (Node.js) + +```typescript +import OpenAI from 'openai'; + +const client = new OpenAI({ + baseURL: 'http://localhost:8080/v1', + apiKey: 'not-needed', +}); + +async function chatStream(message: string) { + const stream = await client.chat.completions.create({ + model: 'qd-job-turbo', + messages: [{ role: 'user', content: message }], + stream: true, + }); + + for await (const chunk of stream) { + process.stdout.write(chunk.choices[0]?.delta?.content || ''); + } + console.log(); +} + +chatStream('帮我推荐青岛城阳区的Java开发岗位'); +``` + +--- + +### 8.4 Go 示例 + +```go +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +type ChatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +func chat(message string) error { + req := ChatRequest{ + Model: "qd-job-turbo", + Messages: []Message{ + {Role: "user", Content: message}, + }, + Stream: true, + } + + body, _ := json.Marshal(req) + resp, err := http.Post( + "http://localhost:8080/v1/chat/completions", + "application/json", + bytes.NewBuffer(body), + ) + if err != nil { + return err + } + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk map[string]interface{} + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + if choices, ok := chunk["choices"].([]interface{}); ok && len(choices) > 0 { + if choice, ok := choices[0].(map[string]interface{}); ok { + if delta, ok := choice["delta"].(map[string]interface{}); ok { + if content, ok := delta["content"].(string); ok { + fmt.Print(content) + } + } + } + } + } + } + fmt.Println() + return nil +} + +func main() { + chat("帮我推荐城阳区的Java开发岗位") +} +``` + +--- + +### 8.5 Vue.js 前端示例 + +```vue + + + + + +``` + +--- + +## 9. 错误处理 + +### 9.1 错误响应格式 + +```json +{ + "error": { + "message": "错误描述信息", + "type": "错误类型", + "code": "错误代码" + } +} +``` + +### 9.2 常见错误码 + +| HTTP 状态码 | 错误类型 | 说明 | +|------------|---------|------| +| 400 | `invalid_request` | 请求格式错误 | +| 400 | `multipart_parse_error` | multipart 表单解析失败 | +| 400 | `file_processing_error` | 文件处理失败 | +| 429 | `rate_limit_exceeded` | 请求过于频繁 | +| 500 | `internal_error` | 服务器内部错误 | + +### 9.3 错误处理建议 + +```python +import requests + +def safe_chat(message: str) -> str: + try: + response = requests.post( + "http://localhost:8080/v1/chat/completions", + json={ + "model": "qd-job-turbo", + "messages": [{"role": "user", "content": message}], + "stream": False + }, + timeout=120 + ) + + if response.status_code == 429: + # 限流,等待后重试 + import time + time.sleep(2) + return safe_chat(message) + + response.raise_for_status() + return response.json()["choices"][0]["message"]["content"] + + except requests.exceptions.Timeout: + return "请求超时,请稍后重试" + except requests.exceptions.RequestException as e: + return f"请求失败: {e}" +``` + +--- + +## 10. 配置参考 + +### 10.1 完整配置文件 (config.yaml) + +```yaml +# 服务器配置 +server: + port: 8080 # 服务端口 + host: "0.0.0.0" # 监听地址 + read_timeout: 30s # 读取请求超时 + write_timeout: 300s # 写入响应超时(流式响应需要更长时间) + +# LLM配置 +llm: + base_url: "https://api.openai.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触发百分比 +``` + +### 10.2 环境变量覆盖 + +环境变量会自动覆盖配置文件中的值: + +| 环境变量 | 配置项 | +|---------|--------| +| `SERVER_PORT` | server.port | +| `SERVER_HOST` | server.host | +| `LLM_API_KEY` | llm.api_key | +| `LLM_BASE_URL` | llm.base_url | +| `LLM_MODEL` | llm.model | +| `AMAP_API_KEY` | amap.api_key | +| `OCR_BASE_URL` | ocr.base_url | +| `JOB_API_BASE_URL` | job_api.base_url | +| `POLICY_BASE_URL` | policy.base_url | +| `POLICY_LOGIN_NAME` | policy.login_name | +| `POLICY_USER_KEY` | policy.user_key | +| `POLICY_SERVICE_ID` | policy.service_id | + +--- + +## 11. 部署指南 + +### 11.1 Docker Compose 部署 + +```yaml +# docker-compose.yml +version: '3.8' + +services: + qd-sc-server: + build: . + ports: + - "8080:8080" + environment: + - LLM_API_KEY=sk-xxx + - LLM_BASE_URL=https://api.openai.com/v1 + - AMAP_API_KEY=your-amap-key + - OCR_BASE_URL=https://your-ocr-api.example.com + volumes: + - ./config.yaml:/app/config.yaml:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 +``` + +### 11.2 Kubernetes 部署 + +```yaml +# deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: qd-sc-server +spec: + replicas: 3 + selector: + matchLabels: + app: qd-sc-server + template: + metadata: + labels: + app: qd-sc-server + spec: + containers: + - name: qd-sc-server + image: qd-sc-server:latest + ports: + - containerPort: 8080 + env: + - name: LLM_API_KEY + valueFrom: + secretKeyRef: + name: qd-sc-secrets + key: llm-api-key + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: qd-sc-server +spec: + selector: + app: qd-sc-server + ports: + - port: 80 + targetPort: 8080 + type: LoadBalancer +``` + +### 11.3 Nginx 反向代理 + +```nginx +upstream qd_sc_backend { + server 127.0.0.1:8080; + keepalive 32; +} + +server { + listen 443 ssl http2; + server_name api.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://qd_sc_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE 支持 + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding off; + + # 超时配置 + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # 文件上传大小限制 + client_max_body_size 20M; +} +``` + +--- + +## 12. 常见问题 + +### Q1: 如何处理流式响应中的岗位数据? + +岗位数据会以特殊的 ```` ``` job-json ```` 代码块格式返回。客户端需要解析这个格式: + +```javascript +function parseJobCards(content) { + const jobRegex = /```\s*job-json\n([\s\S]*?)```/g; + const jobs = []; + let match; + + while ((match = jobRegex.exec(content)) !== null) { + try { + jobs.push(JSON.parse(match[1])); + } catch (e) {} + } + + return jobs; +} +``` + +### Q2: 流式响应中途断开怎么办? + +建议实现重连机制: + +```python +import time + +def chat_with_retry(message, max_retries=3): + for i in range(max_retries): + try: + return chat_stream(message) + except Exception as e: + if i < max_retries - 1: + time.sleep(2 ** i) # 指数退避 + else: + raise e +``` + +### Q3: 如何实现多轮对话? + +保存历史消息并在每次请求中传递: + +```python +conversation = [] + +def chat(user_input): + conversation.append({"role": "user", "content": user_input}) + + response = client.chat.completions.create( + model="qd-job-turbo", + messages=conversation, + stream=True + ) + + assistant_message = "" + for chunk in response: + content = chunk.choices[0].delta.content or "" + assistant_message += content + print(content, end="", flush=True) + + conversation.append({"role": "assistant", "content": assistant_message}) + print() +``` + +### Q4: 429 错误(限流)如何处理? + +系统默认限流为:桶容量 200,每秒补充 50 个令牌。建议: +1. 实现请求重试机制,遇到 429 后等待 1-2 秒重试 +2. 控制客户端的请求频率 +3. 如需更高 QPS,联系管理员调整配置 + +### Q5: 如何调试 API 问题? + +1. 启用 debug 日志级别 +2. (可选)使用 `/metrics` 端点查看性能指标(需启用 `performance.enable_metrics`) +3. (可选)使用 `/debug/pprof/` 进行性能分析(需启用 `performance.enable_pprof`) +4. 检查服务器日志输出 + +--- + +## 附录 + +### A. 响应时间参考 + +| 操作 | 预期响应时间 | +|------|-------------| +| 健康检查 | < 10ms | +| 简单对话 | 2-5s | +| 岗位查询 | 3-8s | +| 文件解析 | 5-30s | +| 政策咨询 | 3-10s | + +### B. 并发能力 + +| 指标 | 参考值 | +|------|--------| +| 最大并发连接 | 10,000 | +| 建议 QPS | 50 | +| 最大文件上传 | 10MB | + +### C. 更新日志 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0.0 | 2025-11 | 初始版本 | + diff --git a/CODE_ARCHITECTURE.md b/CODE_ARCHITECTURE.md new file mode 100644 index 0000000..e5ea1a4 --- /dev/null +++ b/CODE_ARCHITECTURE.md @@ -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` 中初始化并注入 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce3bc93 --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..59693ff --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..75ce986 --- /dev/null +++ b/cmd/server/main.go @@ -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("服务器已完全关闭") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b62895f --- /dev/null +++ b/docker-compose.yml @@ -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 + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e955dc2 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a77fa1 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/handler/chat.go b/internal/api/handler/chat.go new file mode 100644 index 0000000..a9d1592 --- /dev/null +++ b/internal/api/handler/chat.go @@ -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 + } + } +} diff --git a/internal/api/handler/health.go b/internal/api/handler/health.go new file mode 100644 index 0000000..4af0bdd --- /dev/null +++ b/internal/api/handler/health.go @@ -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", + }) +} diff --git a/internal/api/handler/metrics.go b/internal/api/handler/metrics.go new file mode 100644 index 0000000..832d5b9 --- /dev/null +++ b/internal/api/handler/metrics.go @@ -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) +} diff --git a/internal/api/handler/response.go b/internal/api/handler/response.go new file mode 100644 index 0000000..59e236f --- /dev/null +++ b/internal/api/handler/response.go @@ -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() diff --git a/internal/api/middleware/cors.go b/internal/api/middleware/cors.go new file mode 100644 index 0000000..0a2f457 --- /dev/null +++ b/internal/api/middleware/cors.go @@ -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() + } +} diff --git a/internal/api/middleware/cors_test.go b/internal/api/middleware/cors_test.go new file mode 100644 index 0000000..fe92f00 --- /dev/null +++ b/internal/api/middleware/cors_test.go @@ -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) + } +} diff --git a/internal/api/middleware/metrics.go b/internal/api/middleware/metrics.go new file mode 100644 index 0000000..95064c7 --- /dev/null +++ b/internal/api/middleware/metrics.go @@ -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() + } + } +} diff --git a/internal/api/middleware/ratelimit.go b/internal/api/middleware/ratelimit.go new file mode 100644 index 0000000..85dc4ff --- /dev/null +++ b/internal/api/middleware/ratelimit.go @@ -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() + } +} diff --git a/internal/api/middleware/ratelimit_test.go b/internal/api/middleware/ratelimit_test.go new file mode 100644 index 0000000..87d2f6f --- /dev/null +++ b/internal/api/middleware/ratelimit_test.go @@ -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)") + } +} diff --git a/internal/api/middleware/recovery.go b/internal/api/middleware/recovery.go new file mode 100644 index 0000000..c945407 --- /dev/null +++ b/internal/api/middleware/recovery.go @@ -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() + } +} diff --git a/internal/client/amap_client.go b/internal/client/amap_client.go new file mode 100644 index 0000000..391d5a5 --- /dev/null +++ b/internal/client/amap_client.go @@ -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 +} diff --git a/internal/client/http_client.go b/internal/client/http_client.go new file mode 100644 index 0000000..816a76a --- /dev/null +++ b/internal/client/http_client.go @@ -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) +} diff --git a/internal/client/job_client.go b/internal/client/job_client.go new file mode 100644 index 0000000..1d5965d --- /dev/null +++ b/internal/client/job_client.go @@ -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, + } +} diff --git a/internal/client/llm_client.go b/internal/client/llm_client.go new file mode 100644 index 0000000..29e0248 --- /dev/null +++ b/internal/client/llm_client.go @@ -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 +} diff --git a/internal/client/llm_client_test.go b/internal/client/llm_client_test.go new file mode 100644 index 0000000..cb97986 --- /dev/null +++ b/internal/client/llm_client_test.go @@ -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) + } +} diff --git a/internal/client/ocr_client.go b/internal/client/ocr_client.go new file mode 100644 index 0000000..3f17888 --- /dev/null +++ b/internal/client/ocr_client.go @@ -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 +} diff --git a/internal/client/policy_client.go b/internal/client/policy_client.go new file mode 100644 index 0000000..f45183a --- /dev/null +++ b/internal/client/policy_client.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e613c00 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/model/job.go b/internal/model/job.go new file mode 100644 index 0000000..8c95c95 --- /dev/null +++ b/internal/model/job.go @@ -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"` +} diff --git a/internal/model/openai.go b/internal/model/openai.go new file mode 100644 index 0000000..e417e88 --- /dev/null +++ b/internal/model/openai.go @@ -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"` +} diff --git a/internal/model/policy.go b/internal/model/policy.go new file mode 100644 index 0000000..5603e72 --- /dev/null +++ b/internal/model/policy.go @@ -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"` // 查询数据结果 +} diff --git a/internal/model/tool.go b/internal/model/tool.go new file mode 100644 index 0000000..9bb5e4b --- /dev/null +++ b/internal/model/tool.go @@ -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) +} diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go new file mode 100644 index 0000000..690a87f --- /dev/null +++ b/internal/service/chat_service.go @@ -0,0 +1,1042 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log" + "qd-sc/internal/client" + "qd-sc/internal/config" + "qd-sc/internal/model" + "qd-sc/pkg/utils" + "regexp" + "strings" + "time" +) + +// ExposedModelName 对外暴露的固定模型名称 +const ExposedModelName = "qd-job-turbo" + +// 岗位查询意图关键词 +var jobIntentKeywords = []string{ + "岗位", "工作", "招聘", "职位", "就业", "求职", "找工作", "应聘", + "薪资", "薪酬", "工资", "待遇", "月薪", "年薪", + "推荐岗位", "推荐工作", "推荐职位", + "附近的工作", "附近的岗位", "附近招聘", + "适合我的", "匹配的岗位", "匹配的工作", + "开发工程师", "产品经理", "设计师", "运营", "销售", "会计", "财务", + "前端", "后端", "全栈", "Java", "Python", "测试", "运维", +} + +// 简历内容特征关键词(用于判断OCR内容是否是简历) +var resumeKeywords = []string{ + // 个人信息相关 + "姓名", "性别", "年龄", "出生", "籍贯", "民族", "身份证", + "电话", "手机", "邮箱", "邮件", "地址", "住址", + // 教育背景 + "学历", "学位", "毕业", "本科", "硕士", "博士", "大专", "高中", + "专业", "院校", "大学", "学院", "在读", "应届", + // 工作经历 + "工作经验", "工作经历", "任职", "就职", "离职", "在职", + "公司", "企业", "单位", "部门", "岗位", "职位", "职务", + // 技能相关 + "技能", "特长", "证书", "资格", "熟练", "精通", "掌握", + // 自我评价 + "自我评价", "个人简介", "自我介绍", "个人总结", "求职意向", + // 简历特有标记 + "简历", "履历", "个人资料", "基本信息", "联系方式", +} + +// resumeKeywordThreshold 简历关键词匹配阈值(需要匹配到的最小数量) +const resumeKeywordThreshold = 3 + +// isResumeContent 检测OCR内容是否是简历 +func isResumeContent(content string) bool { + if content == "" { + return false + } + + matchCount := 0 + contentLower := strings.ToLower(content) + + for _, keyword := range resumeKeywords { + if strings.Contains(contentLower, strings.ToLower(keyword)) { + matchCount++ + if matchCount >= resumeKeywordThreshold { + log.Printf("检测到简历内容,匹配关键词数量: %d", matchCount) + return true + } + } + } + + log.Printf("内容不符合简历特征,仅匹配到 %d 个关键词(阈值: %d)", matchCount, resumeKeywordThreshold) + return false +} + +// 岗位信息输出的特征模式(用于检测AI幻觉) +var jobHallucinationPatterns = []*regexp.Regexp{ + regexp.MustCompile(`岗位名称[::]\s*\S+`), + regexp.MustCompile(`公司名称[::]\s*\S+`), + regexp.MustCompile(`薪资范围[::]\s*\d+`), + regexp.MustCompile(`工作地点[::]\s*\S+`), + regexp.MustCompile(`学历要求[::]\s*\S+`), + regexp.MustCompile(`经验要求[::]\s*\S+`), + regexp.MustCompile(`\d+[-~到至]\d+元[//每]月`), + regexp.MustCompile(`\d+[kK][-~到至]\d+[kK]`), + regexp.MustCompile(`(?:推荐|适合)[^。]*(?:岗位|职位|工作)[::]\s*\d+[.、]`), + regexp.MustCompile(`以下是[^。]*(?:岗位|职位|工作)`), + regexp.MustCompile(`为您(?:推荐|找到)[^。]*(?:岗位|职位|工作)`), +} + +// containsNonResumeImageHint 检测消息中是否包含非简历图片的提示 +func containsNonResumeImageHint(content string) bool { + return strings.Contains(content, "[用户上传的图片内容(非简历格式)]") +} + +// isJobQueryIntent 检测用户输入是否是岗位查询意图 +func (s *ChatService) isJobQueryIntent(messages []model.Message) bool { + // 获取最后一条用户消息 + var lastUserMessage string + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + if content, ok := messages[i].Content.(string); ok { + lastUserMessage = content + break + } + } + } + + if lastUserMessage == "" { + return false + } + + // 如果消息中包含非简历图片的提示,不视为岗位查询意图 + // 因为需要先询问用户意图 + if containsNonResumeImageHint(lastUserMessage) { + log.Printf("检测到非简历图片,不强制岗位查询意图,需先询问用户") + return false + } + + // 转换为小写进行匹配 + lowerMsg := strings.ToLower(lastUserMessage) + + // 检查是否包含岗位相关关键词 + for _, keyword := range jobIntentKeywords { + if strings.Contains(lowerMsg, strings.ToLower(keyword)) { + log.Printf("检测到岗位查询意图,匹配关键词: %s", keyword) + return true + } + } + + return false +} + +// containsJobHallucination 检测AI回复是否包含岗位幻觉(在没有调用工具的情况下自行输出岗位信息) +func (s *ChatService) containsJobHallucination(content string) bool { + if content == "" { + return false + } + + matchCount := 0 + for _, pattern := range jobHallucinationPatterns { + if pattern.MatchString(content) { + matchCount++ + if matchCount >= 2 { + log.Printf("检测到岗位幻觉输出,匹配模式数量: %d", matchCount) + return true + } + } + } + + return false +} + +// getJobToolChoice 获取强制调用岗位工具的 tool_choice 配置 +func (s *ChatService) getJobToolChoice() interface{} { + // 返回 "required" 强制模型必须调用某个工具 + // 或者返回特定工具的配置强制调用该工具 + return map[string]interface{}{ + "type": "function", + "function": map[string]string{ + "name": "queryJobsByArea", + }, + } +} + +// getHallucinationWarningMessage 获取幻觉拦截后的警告消息 +func (s *ChatService) getHallucinationWarningMessage() string { + return "抱歉,我需要先查询实际的岗位数据才能为您推荐。请稍等,我正在为您搜索符合条件的岗位..." +} + +// ChatService 对话服务 +type ChatService struct { + cfg *config.Config + llmClient *client.LLMClient + ocrClient *client.OCRClient + locationService *LocationService + jobService *JobService + policyService *PolicyService +} + +// NewChatService 创建对话服务 +func NewChatService( + cfg *config.Config, + llmClient *client.LLMClient, + ocrClient *client.OCRClient, + locationService *LocationService, + jobService *JobService, + policyService *PolicyService, +) *ChatService { + return &ChatService{ + cfg: cfg, + llmClient: llmClient, + ocrClient: ocrClient, + locationService: locationService, + jobService: jobService, + policyService: policyService, + } +} + +// ProcessChatRequest 处理聊天请求 +func (s *ChatService) ProcessChatRequest(req *model.ChatCompletionRequest) (*model.ChatCompletionResponse, error) { + // 准备消息 + messages := s.prepareMessages(req.Messages) + + // 添加工具定义 + tools := model.GetAvailableTools() + + // 检测是否是岗位查询意图 + isJobIntent := s.isJobQueryIntent(req.Messages) + var toolChoice interface{} = "auto" + if isJobIntent { + // 岗位场景:强制要求调用工具 + toolChoice = "required" + log.Printf("检测到岗位查询意图,设置 tool_choice=required") + } + + // 构建请求(使用配置文件中的实际模型名称) + llmReq := &model.ChatCompletionRequest{ + Model: s.cfg.LLM.Model, + Messages: messages, + Tools: tools, + ToolChoice: toolChoice, + Temperature: req.Temperature, + TopP: req.TopP, + MaxTokens: req.MaxTokens, + } + + // 追踪是否已调用过岗位工具 + jobToolCalled := false + + // 开始对话循环(支持多轮工具调用) + maxIterations := 10 + for i := 0; i < maxIterations; i++ { + resp, err := s.llmClient.ChatCompletion(llmReq) + if err != nil { + return nil, fmt.Errorf("LLM请求失败: %w", err) + } + + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("LLM返回空结果") + } + + choice := resp.Choices[0] + + // 检查finish_reason,如果是"stop"表示对话已完成 + if choice.FinishReason == "stop" && len(choice.Message.ToolCalls) == 0 { + // 岗位意图场景下的幻觉检测 + if isJobIntent && !jobToolCalled { + if content, ok := choice.Message.Content.(string); ok { + if s.containsJobHallucination(content) { + log.Printf("拦截岗位幻觉输出,强制重新调用工具") + // 修改消息,添加系统提示强制调用工具 + llmReq.Messages = append(llmReq.Messages, model.Message{ + Role: "user", + Content: "请调用岗位查询工具获取真实数据,不要自行编造岗位信息。", + }) + llmReq.ToolChoice = s.getJobToolChoice() + continue + } + } + } + log.Printf("模型返回finish_reason=stop,对话结束") + return resp, nil + } + + // 如果没有工具调用,返回最终结果 + if len(choice.Message.ToolCalls) == 0 { + // 岗位意图场景下的幻觉检测 + if isJobIntent && !jobToolCalled { + if content, ok := choice.Message.Content.(string); ok { + if s.containsJobHallucination(content) { + log.Printf("拦截岗位幻觉输出,强制重新调用工具") + llmReq.Messages = append(llmReq.Messages, model.Message{ + Role: "user", + Content: "请调用岗位查询工具获取真实数据,不要自行编造岗位信息。", + }) + llmReq.ToolChoice = s.getJobToolChoice() + continue + } + } + } + return resp, nil + } + + // 处理工具调用 + log.Printf("检测到工具调用,finish_reason: %s", choice.FinishReason) + llmReq.Messages = append(llmReq.Messages, choice.Message) + + for _, toolCall := range choice.Message.ToolCalls { + // 检查是否是岗位工具调用 + if toolCall.Function.Name == "queryJobsByArea" || toolCall.Function.Name == "queryJobsByLocation" { + jobToolCalled = true + } + + result, err := s.executeToolCall(&toolCall) + if err != nil { + result = fmt.Sprintf("工具调用失败: %s", err.Error()) + log.Printf("工具调用失败 [%s]: %v", toolCall.Function.Name, err) + } + + // 添加工具响应 + llmReq.Messages = append(llmReq.Messages, model.Message{ + Role: "tool", + Content: result, + ToolCallID: toolCall.ID, + }) + } + + // 岗位工具调用成功后,恢复为 auto 模式 + if jobToolCalled { + llmReq.ToolChoice = "auto" + } + } + + return nil, fmt.Errorf("超过最大工具调用次数") +} + +// ProcessChatRequestStream 处理聊天请求(流式) +func (s *ChatService) ProcessChatRequestStream(ctx context.Context, req *model.ChatCompletionRequest) (chan *model.ChatCompletionChunk, chan error) { + chunkChan := make(chan *model.ChatCompletionChunk, 100) + errChan := make(chan error, 1) + + go func() { + defer close(chunkChan) + defer close(errChan) + + // 准备消息 + messages := s.prepareMessages(req.Messages) + + // 添加工具定义 + tools := model.GetAvailableTools() + + // 检测是否是岗位查询意图 + isJobIntent := s.isJobQueryIntent(req.Messages) + var toolChoice interface{} = "auto" + if isJobIntent { + // 岗位场景:强制要求调用工具 + toolChoice = "required" + log.Printf("【流式】检测到岗位查询意图,设置 tool_choice=required") + } + + // 构建请求(使用配置文件中的实际模型名称) + llmReq := &model.ChatCompletionRequest{ + Model: s.cfg.LLM.Model, + Messages: messages, + Tools: tools, + ToolChoice: toolChoice, + Temperature: req.Temperature, + TopP: req.TopP, + MaxTokens: req.MaxTokens, + Stream: true, + } + + // 追踪是否已发送第一个chunk(用于正确设置role) + firstChunkSent := false + + // 追踪是否已调用过岗位工具 + jobToolCalled := false + + // 追踪是否已经发送过幻觉拦截消息(避免重复发送) + hallucinationIntercepted := false + + // 开始对话循环 + maxIterations := 10 + for iteration := 0; iteration < maxIterations; iteration++ { + // 检查context是否已取消 + select { + case <-ctx.Done(): + log.Printf("请求被取消: %v", ctx.Err()) + return + default: + } + responseChan, respErrChan, err := s.llmClient.ChatCompletionStream(llmReq) + if err != nil { + errChan <- fmt.Errorf("LLM流式请求失败: %w", err) + return + } + + var currentMessage model.Message + var toolCalls []model.ToolCall + var finishReason string + currentMessage.Role = "assistant" + + // 用于合并重复日志的计数器 + filteredToolCallsCount := 0 + filteredFinishReasonCount := 0 + + // 用于岗位幻觉检测的内容缓冲(岗位场景下先缓冲,检测后再决定是否转发) + var contentBuffer strings.Builder + var pendingChunks []*model.ChatCompletionChunk + + // 收集流式响应 + for { + select { + case <-ctx.Done(): + // context被取消,停止处理 + log.Printf("流式处理被取消: %v", ctx.Err()) + return + case chunk, ok := <-responseChan: + if !ok { + responseChan = nil + break + } + + // 处理chunk的role字段:只有第一个chunk保留role,后续chunk清除role + if len(chunk.Choices) > 0 { + if !firstChunkSent { + // 第一个chunk,确保有role="assistant" + if chunk.Choices[0].Delta.Role == "" { + chunk.Choices[0].Delta.Role = "assistant" + } + firstChunkSent = true + } else { + // 后续chunks,清除role让omitempty生效 + chunk.Choices[0].Delta.Role = "" + } + } + + // 收集消息内容和finish_reason(在转发之前) + if len(chunk.Choices) > 0 { + delta := chunk.Choices[0].Delta + + if content, ok := delta.Content.(string); ok && content != "" { + if currentMessage.Content == nil { + currentMessage.Content = "" + } + currentMessage.Content = currentMessage.Content.(string) + content + // 同时写入缓冲区 + contentBuffer.WriteString(content) + } + + // 收集工具调用(注意:流式响应中工具调用可能分块到达) + if len(delta.ToolCalls) > 0 { + toolCalls = append(toolCalls, delta.ToolCalls...) + } + + // 收集finish_reason + if chunk.Choices[0].FinishReason != "" { + finishReason = chunk.Choices[0].FinishReason + log.Printf("收到finish_reason: %s", finishReason) + } + } + + // 只转发内容chunk,不转发包含tool_calls或finish_reason=tool_calls的chunk + // 因为我们的工具调用是在服务端自动处理的,不需要客户端参与 + shouldForward := true + if len(chunk.Choices) > 0 { + delta := chunk.Choices[0].Delta + // 如果chunk包含tool_calls,不转发 + if len(delta.ToolCalls) > 0 { + shouldForward = false + filteredToolCallsCount++ + } + // 如果finish_reason是tool_calls,不转发 + if chunk.Choices[0].FinishReason == "tool_calls" { + shouldForward = false + filteredFinishReasonCount++ + } + } + + // 岗位意图场景下的特殊处理:先缓冲内容,检测幻觉后再决定转发 + if shouldForward && isJobIntent && !jobToolCalled && len(toolCalls) == 0 { + // 岗位场景且未调用工具:缓冲chunk,稍后检测 + chunkCopy := *chunk + pendingChunks = append(pendingChunks, &chunkCopy) + } else if shouldForward { + // 非岗位场景或已调用工具:直接转发 + chunkChan <- chunk + } + + case err, ok := <-respErrChan: + if ok && err != nil { + errChan <- err + return + } + } + + if responseChan == nil { + break + } + } + + // 输出合并后的过滤日志 + if filteredToolCallsCount > 0 { + log.Printf("过滤tool_calls chunk x%d,不转发给客户端", filteredToolCallsCount) + } + if filteredFinishReasonCount > 0 { + log.Printf("过滤finish_reason=tool_calls chunk x%d,不转发给客户端", filteredFinishReasonCount) + } + + // 岗位意图场景下的幻觉检测 + if isJobIntent && !jobToolCalled && len(toolCalls) == 0 { + bufferedContent := contentBuffer.String() + if s.containsJobHallucination(bufferedContent) { + log.Printf("【流式】拦截岗位幻觉输出,内容长度: %d,丢弃缓冲的 %d 个chunks", len(bufferedContent), len(pendingChunks)) + + // 不转发幻觉内容,发送警告消息并强制重新调用工具 + if !hallucinationIntercepted { + hallucinationIntercepted = true + // 发送警告消息给用户 + warningChunk := &model.ChatCompletionChunk{ + ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: ExposedModelName, + Choices: []model.ChunkChoice{ + { + Index: 0, + Delta: model.Message{ + Content: s.getHallucinationWarningMessage(), + }, + }, + }, + } + chunkChan <- warningChunk + } + + // 修改消息,强制调用工具 + llmReq.Messages = append(llmReq.Messages, model.Message{ + Role: "user", + Content: "请调用岗位查询工具获取真实数据,不要自行编造岗位信息。", + }) + llmReq.ToolChoice = s.getJobToolChoice() + continue + } else { + // 没有幻觉,转发缓冲的chunks + for _, pendingChunk := range pendingChunks { + chunkChan <- pendingChunk + } + } + } + + // 根据finish_reason决定是否继续 + // 如果finish_reason是"stop",表示模型认为对话已完成,应该结束 + if finishReason == "stop" { + log.Printf("模型返回finish_reason=stop,对话结束") + return + } + + // 如果没有工具调用,结束 + if len(toolCalls) == 0 { + return + } + + // 合并工具调用 + currentMessage.ToolCalls = s.mergeToolCalls(toolCalls) + llmReq.Messages = append(llmReq.Messages, currentMessage) + + // 执行工具调用并继续对话 + for _, toolCall := range currentMessage.ToolCalls { + // 检查是否是岗位工具调用 + if toolCall.Function.Name == "queryJobsByArea" || toolCall.Function.Name == "queryJobsByLocation" { + jobToolCalled = true + } + + result, err := s.executeToolCall(&toolCall) + var callSuccess bool + + if err != nil { + result = fmt.Sprintf("工具调用失败: %s", err.Error()) + log.Printf("工具调用失败 [%s]: %v", toolCall.Function.Name, err) + callSuccess = false + } else { + callSuccess = true + } + + // 检查是否是岗位查询工具,且调用成功,需要分块输出 + if callSuccess && (toolCall.Function.Name == "queryJobsByArea" || toolCall.Function.Name == "queryJobsByLocation") { + // 分块输出岗位信息 + if err := s.streamJobResults(chunkChan, result, ExposedModelName); err != nil { + log.Printf("流式输出岗位失败: %v", err) + } + + // 岗位展示完成后,直接发送一个空的final chunk结束对话 + // 这样客户端会正确识别对话已完成,不会再发起后续请求 + finalChunk := &model.ChatCompletionChunk{ + ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: ExposedModelName, + Choices: []model.ChunkChoice{ + { + Index: 0, + Delta: model.Message{}, + FinishReason: "stop", + }, + }, + } + chunkChan <- finalChunk + log.Printf("岗位推荐完成,发送finish_reason=stop并结束") + return + } + + // 确保result不为空 + if result == "" { + result = "工具执行完成" + } + + llmReq.Messages = append(llmReq.Messages, model.Message{ + Role: "tool", + Content: result, + ToolCallID: toolCall.ID, + }) + } + + // 岗位工具调用成功后,恢复为 auto 模式 + if jobToolCalled { + llmReq.ToolChoice = "auto" + } + + // 发送一个提示chunk,表示正在处理工具调用 + // role留空,因为这不是第一个chunk + chunkChan <- &model.ChatCompletionChunk{ + ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: ExposedModelName, + Choices: []model.ChunkChoice{ + { + Index: 0, + Delta: model.Message{ + Content: "\n\n", + }, + }, + }, + } + } + + errChan <- fmt.Errorf("超过最大工具调用次数") + }() + + return chunkChan, errChan +} + +// prepareMessages 准备消息列表 +func (s *ChatService) prepareMessages(userMessages []model.Message) []model.Message { + messages := []model.Message{ + { + Role: "system", + Content: model.GetSystemPrompt(), + }, + } + + // 处理用户消息,支持 OpenAI Vision API 格式的文件URL消息 + for _, msg := range userMessages { + processedMsg := s.processMessageWithFileURLs(msg) + messages = append(messages, processedMsg) + } + + return messages +} + +// processMessageWithFileURLs 处理消息中的文件URL,使用OCR服务解析 +// 支持 OpenAI Vision API 兼容格式(image_url 字段),可解析图片、PDF、Excel、PPT 等文件 +func (s *ChatService) processMessageWithFileURLs(msg model.Message) model.Message { + // 检查 Content 是否是数组类型(OpenAI Vision API 格式) + contentArray, ok := msg.Content.([]interface{}) + if !ok { + // 不是数组,直接返回原消息 + return msg + } + + var textParts []string + var imageContents []string + + for _, item := range contentArray { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + contentType, _ := itemMap["type"].(string) + + switch contentType { + case "text": + if text, ok := itemMap["text"].(string); ok { + textParts = append(textParts, text) + } + case "image_url": + // 处理图片 URL + imageURLData, ok := itemMap["image_url"].(map[string]interface{}) + if !ok { + continue + } + imageURL, ok := imageURLData["url"].(string) + if !ok || imageURL == "" { + continue + } + + // 使用 OCR 服务解析图片 + log.Printf("检测到图片URL,使用OCR服务解析: %s", imageURL) + ocrContent, err := s.ocrClient.ParseURL(imageURL) + if err != nil { + log.Printf("OCR解析图片失败: %v", err) + imageContents = append(imageContents, fmt.Sprintf("[图片解析失败: %s]", err.Error())) + } else { + log.Printf("OCR解析图片成功,内容长度: %d", len(ocrContent)) + + // 检测OCR内容是否是简历 + if isResumeContent(ocrContent) { + // 是简历,正常处理 + imageContents = append(imageContents, fmt.Sprintf("[用户上传的简历内容]:\n%s", ocrContent)) + } else { + // 不是简历,添加提示让模型先询问用户意图 + imageContents = append(imageContents, fmt.Sprintf("[用户上传的图片内容(非简历格式)]:\n%s\n\n[重要提示]: 该图片内容不像标准简历,请先询问用户上传这张图片的意图是什么,确认用户需求后再提供相应帮助。不要直接假设用户想找工作。", ocrContent)) + log.Printf("OCR内容不是简历格式,已添加询问用户意图的提示") + } + } + } + } + + // 合并文本和图片内容 + var finalContent string + if len(textParts) > 0 { + finalContent = strings.Join(textParts, "\n") + } + if len(imageContents) > 0 { + if finalContent != "" { + finalContent += "\n\n" + } + finalContent += strings.Join(imageContents, "\n\n") + } + + return model.Message{ + Role: msg.Role, + Content: finalContent, + Name: msg.Name, + } +} + +// executeToolCall 执行工具调用 +func (s *ChatService) executeToolCall(toolCall *model.ToolCall) (string, error) { + funcName := toolCall.Function.Name + arguments := toolCall.Function.Arguments + + log.Printf("执行工具调用: %s", funcName) + log.Printf("工具参数: %s", arguments) + + // 验证参数不为空 + if arguments == "" { + return "", fmt.Errorf("工具参数为空") + } + + // 解析参数 + var params map[string]interface{} + if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil { + log.Printf("参数解析失败,原始参数: [%s]", arguments) + return "", fmt.Errorf("解析工具参数失败: %w", err) + } + + log.Printf("解析后的参数: %+v", params) + + // 根据工具名称执行相应操作 + switch funcName { + case "queryLocation": + return s.handleQueryLocation(params) + case "queryJobsByArea": + return s.handleQueryJobsByArea(params) + case "queryJobsByLocation": + return s.handleQueryJobsByLocation(params) + case "parsePDF": + return s.handleParsePDF(params) + case "parseImage": + return s.handleParseImage(params) + case "queryPolicy": + return s.handleQueryPolicy(params) + default: + return "", fmt.Errorf("未知的工具: %s", funcName) + } +} + +// handleQueryLocation 处理地理位置查询 +func (s *ChatService) handleQueryLocation(params map[string]interface{}) (string, error) { + keywords, ok := params["keywords"].(string) + if !ok { + return "", fmt.Errorf("缺少keywords参数") + } + + lat, lng, err := s.locationService.QueryLocation(keywords) + if err != nil { + return "", err + } + + result := map[string]string{ + "keywords": keywords, + "latitude": lat, + "longitude": lng, + "message": fmt.Sprintf("成功获取地点 %s 的坐标", keywords), + } + + return utils.ToJSONStringPretty(result) +} + +// handleQueryJobsByArea 处理按区域查询岗位 +func (s *ChatService) handleQueryJobsByArea(params map[string]interface{}) (string, error) { + return s.jobService.QueryJobsByArea(params) +} + +// handleQueryJobsByLocation 处理按位置查询岗位 +func (s *ChatService) handleQueryJobsByLocation(params map[string]interface{}) (string, error) { + return s.jobService.QueryJobsByLocation(params) +} + +// handleParsePDF 处理PDF解析 +func (s *ChatService) handleParsePDF(params map[string]interface{}) (string, error) { + _, ok := params["fileUrl"].(string) + if !ok { + return "", fmt.Errorf("缺少fileUrl参数") + } + + // 这里应该调用OCR服务解析,但由于URL可能是本地路径,需要特殊处理 + // 实际使用时,文件已在上传阶段被OCR服务解析,此工具仅作为备用 + return "", fmt.Errorf("PDF解析功能需要配合文件上传使用") +} + +// handleParseImage 处理图片解析 +func (s *ChatService) handleParseImage(params map[string]interface{}) (string, error) { + _, ok := params["imageUrl"].(string) + if !ok { + return "", fmt.Errorf("缺少imageUrl参数") + } + + // 这里应该调用视觉模型解析,但由于URL可能是本地路径,需要特殊处理 + // 实际使用时,文件已在上传阶段被解析,此工具仅作为备用 + return "", fmt.Errorf("图片解析功能需要配合文件上传使用") +} + +// handleQueryPolicy 处理政策咨询 +func (s *ChatService) handleQueryPolicy(params map[string]interface{}) (string, error) { + message, ok := params["message"].(string) + if !ok || message == "" { + return "", fmt.Errorf("缺少message参数") + } + + // 可选参数 + chatID := "" + conversationID := "" + realName := false + aac001 := "" + aac147 := "" + aac003 := "" + + if v, ok := params["chatId"].(string); ok { + chatID = v + } + if v, ok := params["conversationId"].(string); ok { + conversationID = v + } + if v, ok := params["realName"].(bool); ok { + realName = v + } + if v, ok := params["aac001"].(string); ok { + aac001 = v + } + if v, ok := params["aac147"].(string); ok { + aac147 = v + } + if v, ok := params["aac003"].(string); ok { + aac003 = v + } + + // 如果是实名咨询,验证必要参数 + if realName && (aac001 == "" || aac147 == "" || aac003 == "") { + return "", fmt.Errorf("实名咨询需要提供个人编号(aac001)、身份证号(aac147)和姓名(aac003)") + } + + // 调用政策咨询服务 + responseMsg, newChatID, newConversationID, err := s.policyService.QueryPolicy( + message, chatID, conversationID, realName, aac001, aac147, aac003, + ) + if err != nil { + return "", err + } + + // 构建返回结果(包含chatID和conversationID供后续多轮对话使用) + result := map[string]interface{}{ + "message": responseMsg, + "chatId": newChatID, + "conversationId": newConversationID, + } + + resultJSON, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("序列化结果失败: %w", err) + } + + return string(resultJSON), nil +} + +// mergeToolCalls 合并流式响应中的工具调用 +// 流式响应中,每个工具调用会分成多个chunk,每个chunk可能只包含几个字符 +// 需要按照每个chunk中delta.tool_calls的index字段来正确合并 +func (s *ChatService) mergeToolCalls(toolCalls []model.ToolCall) []model.ToolCall { + if len(toolCalls) == 0 { + return nil + } + + // 使用map按index合并 + mergedMap := make(map[int]*model.ToolCall) + maxIndex := -1 + + for _, tc := range toolCalls { + // 获取index + idx := 0 + if tc.Index != nil { + idx = *tc.Index + } + + if idx > maxIndex { + maxIndex = idx + } + + // 如果该index已存在,合并数据 + if existing, ok := mergedMap[idx]; ok { + // 合并ID(只在第一次出现时设置) + if tc.ID != "" { + existing.ID = tc.ID + } + // 合并Type + if tc.Type != "" { + existing.Type = tc.Type + } + // 累加函数名称 + if tc.Function.Name != "" { + existing.Function.Name += tc.Function.Name + } + // 累加参数 + if tc.Function.Arguments != "" { + existing.Function.Arguments += tc.Function.Arguments + } + } else { + // 新的工具调用 + tcCopy := tc + mergedMap[idx] = &tcCopy + } + } + + // 按index顺序转换为数组 + result := make([]model.ToolCall, 0, len(mergedMap)) + for i := 0; i <= maxIndex; i++ { + if tc, ok := mergedMap[i]; ok { + // 验证工具调用是否完整 + if tc.Function.Name != "" && tc.Function.Arguments != "" { + result = append(result, *tc) + log.Printf("合并后的工具调用 [%d]: %s, 参数长度: %d", i, tc.Function.Name, len(tc.Function.Arguments)) + } else { + log.Printf("警告:工具调用 [%d] 不完整 - Name: '%s', Args length: %d", + i, tc.Function.Name, len(tc.Function.Arguments)) + } + } + } + + return result +} + +// streamJobResults 流式输出岗位结果(分块,每个岗位间隔1秒) +func (s *ChatService) streamJobResults(chunkChan chan *model.ChatCompletionChunk, jobsJSON string, modelName string) error { + // 解析岗位列表 + var jobResp model.JobResponse + if err := json.Unmarshal([]byte(jobsJSON), &jobResp); err != nil { + return fmt.Errorf("解析岗位数据失败: %w", err) + } + + if len(jobResp.JobListings) == 0 { + // 没有岗位,发送提示信息(role留空,不是第一个chunk) + chunk := &model.ChatCompletionChunk{ + ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: modelName, + Choices: []model.ChunkChoice{ + { + Index: 0, + Delta: model.Message{ + Content: "\n\n未找到符合条件的岗位。\n", + }, + }, + }, + } + chunkChan <- chunk + return nil + } + + // 发送提示信息(role留空,不是第一个chunk) + introChunk := &model.ChatCompletionChunk{ + ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: modelName, + Choices: []model.ChunkChoice{ + { + Index: 0, + Delta: model.Message{ + Content: fmt.Sprintf("\n\n为您找到 %d 个相关岗位:\n\n", len(jobResp.JobListings)), + }, + }, + }, + } + chunkChan <- introChunk + + // 逐个输出岗位,每个间隔1秒 + for i, job := range jobResp.JobListings { + // 如果是最后一个岗位且有data字段,添加data + if i == len(jobResp.JobListings)-1 && jobResp.Data != nil { + job.Data = jobResp.Data + } + + // 将岗位格式化为JSON + jobJSON, err := json.MarshalIndent(job, "", " ") + if err != nil { + log.Printf("格式化岗位失败: %v", err) + continue + } + + // 使用 ``` job-json 包裹 + jobContent := fmt.Sprintf("``` job-json\n%s\n```\n\n", string(jobJSON)) + + // 发送岗位chunk(role留空,不是第一个chunk) + jobChunk := &model.ChatCompletionChunk{ + ID: fmt.Sprintf("chatcmpl-%d-%d", time.Now().Unix(), i), + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: modelName, + Choices: []model.ChunkChoice{ + { + Index: 0, + Delta: model.Message{ + Content: jobContent, + }, + }, + }, + } + chunkChan <- jobChunk + + // 间隔1秒(除了最后一个) + if i < len(jobResp.JobListings)-1 { + time.Sleep(1 * time.Second) + } + } + + return nil +} diff --git a/internal/service/job_service.go b/internal/service/job_service.go new file mode 100644 index 0000000..5b27515 --- /dev/null +++ b/internal/service/job_service.go @@ -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) +} diff --git a/internal/service/location_service.go b/internal/service/location_service.go new file mode 100644 index 0000000..4a06eb4 --- /dev/null +++ b/internal/service/location_service.go @@ -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 +} diff --git a/internal/service/policy_service.go b/internal/service/policy_service.go new file mode 100644 index 0000000..d4d7452 --- /dev/null +++ b/internal/service/policy_service.go @@ -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 +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go new file mode 100644 index 0000000..2164b84 --- /dev/null +++ b/pkg/metrics/metrics.go @@ -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() +} diff --git a/pkg/utils/json.go b/pkg/utils/json.go new file mode 100644 index 0000000..b7c5463 --- /dev/null +++ b/pkg/utils/json.go @@ -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 +}