Initial commit: AI Interview System
This commit is contained in:
64
.drone.yml
Normal file
64
.drone.yml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build-and-deploy
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# 构建后端镜像
|
||||||
|
- name: build-backend
|
||||||
|
image: docker:dind
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- docker build -t ai-interview-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend .
|
||||||
|
- docker tag ai-interview-backend:${DRONE_COMMIT_SHA:0:8} ai-interview-backend:latest
|
||||||
|
|
||||||
|
# 构建前端镜像
|
||||||
|
- name: build-frontend
|
||||||
|
image: docker:dind
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- docker build -t ai-interview-frontend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend .
|
||||||
|
- docker tag ai-interview-frontend:${DRONE_COMMIT_SHA:0:8} ai-interview-frontend:latest
|
||||||
|
|
||||||
|
# 部署到服务器
|
||||||
|
- name: deploy
|
||||||
|
image: docker:dind
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
COZE_PAT_TOKEN:
|
||||||
|
from_secret: coze_pat_token
|
||||||
|
COZE_BOT_ID:
|
||||||
|
from_secret: coze_bot_id
|
||||||
|
COZE_WORKFLOW_A_ID:
|
||||||
|
from_secret: coze_workflow_a_id
|
||||||
|
COZE_WORKFLOW_C_ID:
|
||||||
|
from_secret: coze_workflow_c_id
|
||||||
|
FILE_SERVER_URL:
|
||||||
|
from_secret: file_server_url
|
||||||
|
FILE_SERVER_TOKEN:
|
||||||
|
from_secret: file_server_token
|
||||||
|
commands:
|
||||||
|
- docker stop ai-interview-backend ai-interview-frontend || true
|
||||||
|
- docker rm ai-interview-backend ai-interview-frontend || true
|
||||||
|
- docker run -d --name ai-interview-backend -p 8000:8000 --restart unless-stopped -e COZE_PAT_TOKEN=$COZE_PAT_TOKEN -e COZE_BOT_ID=$COZE_BOT_ID -e COZE_WORKFLOW_A_ID=$COZE_WORKFLOW_A_ID -e COZE_WORKFLOW_C_ID=$COZE_WORKFLOW_C_ID -e FILE_SERVER_URL=$FILE_SERVER_URL -e FILE_SERVER_TOKEN=$FILE_SERVER_TOKEN ai-interview-backend:latest
|
||||||
|
- docker run -d --name ai-interview-frontend -p 3000:80 --restart unless-stopped ai-interview-frontend:latest
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
host:
|
||||||
|
path: /var/run/docker.sock
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 环境变量
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# 依赖目录
|
||||||
|
node_modules/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDE 配置
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# 敏感信息
|
||||||
|
*credentials*.json
|
||||||
|
*secret*.json
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# 本地历史
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# 测试覆盖率
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# n8n 本地数据(如有)
|
||||||
|
.n8n/
|
||||||
104
CONTEXT.md
Normal file
104
CONTEXT.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 项目上下文
|
||||||
|
|
||||||
|
> AI启动时必读此文件,快速了解项目全貌
|
||||||
|
|
||||||
|
## 一、项目信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **项目编号** | 011-ai-interview-2601 |
|
||||||
|
| **项目路径** | `projects/011-ai-interview-2601/` |
|
||||||
|
| **当前阶段** | 开发阶段 - 核心功能完成,语音模式优化中 |
|
||||||
|
| **项目状态** | 🟡 开发中 - 文字模式可用,语音模式待优化 |
|
||||||
|
| **启动日期** | 2026-01-20 |
|
||||||
|
| **技术栈** | Vue3 + TypeScript + FastAPI + Coze API + @coze/realtime-api |
|
||||||
|
|
||||||
|
## 二、AI启动指令
|
||||||
|
|
||||||
|
请依次阅读以下文件:
|
||||||
|
|
||||||
|
1. **框架层**(了解规则)
|
||||||
|
- `../../_framework/agents/00-框架总览.md`
|
||||||
|
- 检查 `agents/` 是否有项目覆盖
|
||||||
|
|
||||||
|
2. **项目文档**(了解当前状态)
|
||||||
|
- `docs/同步清单.md`
|
||||||
|
- `docs/项目状态快照.md`
|
||||||
|
- `docs/决策记录.md`(最近10条)
|
||||||
|
|
||||||
|
3. **技术选型**(本项目有覆盖)
|
||||||
|
- `docs/技术选型.md` ⚠️ 必读,包含框架规范覆盖说明
|
||||||
|
|
||||||
|
## 三、文件访问边界
|
||||||
|
|
||||||
|
| 区域 | 读取 | 写入 |
|
||||||
|
|------|------|------|
|
||||||
|
| ✅ 本项目目录 | 允许 | 允许 |
|
||||||
|
| ✅ `_framework/` | 允许 | ⚠️ 需确认 |
|
||||||
|
| ⚠️ `_private/` | 需许可 | ❌ **绝对禁止** |
|
||||||
|
| ❌ 其他项目 | 禁止 | 禁止 |
|
||||||
|
|
||||||
|
## 四、关键联系人
|
||||||
|
|
||||||
|
| 角色 | 姓名 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 项目负责人 | | |
|
||||||
|
| 技术负责人 | | |
|
||||||
|
|
||||||
|
## 五、项目简介
|
||||||
|
|
||||||
|
**AI Interview** - AI 语音面试系统
|
||||||
|
|
||||||
|
为轻医美行业打造 AI 面试官系统,实现咨询师岗位的智能初试:
|
||||||
|
1. 候选人访问网页,输入姓名、上传简历
|
||||||
|
2. 系统"模拟来电",候选人接听后进入实时语音面试
|
||||||
|
3. AI 面试官按预设流程提问(销售技能、销售观、素质项、求职动机)
|
||||||
|
4. 面试结束后,系统生成评分和分析报告
|
||||||
|
5. HR/管理员在后台查看候选人的完整分析报告
|
||||||
|
|
||||||
|
## 六、现有资源
|
||||||
|
|
||||||
|
| 资源 | ID | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| Workflow A (初始化) | 7597357422713798710 | 接收 name + file_url,生成 session_id |
|
||||||
|
| Workflow B (面试) | 7595077233002840079 | 4 维度提问、评分、生成报告 |
|
||||||
|
| Workflow C (查询) | 7597376294612107318 | 数据库增删改查封装 |
|
||||||
|
| Coze 数据库 | 7595077053909712922 | assessments, logs, config 三表 |
|
||||||
|
| 文件服务器 | files.test.ai.ireborn.com.cn | 自建 Nginx + PHP 文件服务 |
|
||||||
|
|
||||||
|
## 七、技术栈覆盖
|
||||||
|
|
||||||
|
> ⚠️ 本项目与框架规范有以下差异,详见 `docs/技术选型.md`
|
||||||
|
|
||||||
|
| 项目 | 框架默认 | 本项目 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| AI 网关 | OpenRouter | Coze API |
|
||||||
|
| 数据存储 | MySQL | Coze 数据库 |
|
||||||
|
| 实时音视频 | - | 火山引擎 RTC |
|
||||||
|
|
||||||
|
## 八、注意事项
|
||||||
|
|
||||||
|
- Coze PAT Token 和 RTC AppKey 必须存储在后端,禁止暴露给前端
|
||||||
|
- 语音格式需匹配 Coze 支持的格式(推荐 PCM 16000Hz)
|
||||||
|
- 需处理网络不稳定时的断线重连逻辑
|
||||||
|
|
||||||
|
## 九、进度文档
|
||||||
|
|
||||||
|
- 📋 **最新进度**: `docs/项目进度总结-20260121-final.md`
|
||||||
|
- 📚 **框架经验**: `_framework/specs/Coze集成经验.md`
|
||||||
|
|
||||||
|
## 十、已完成功能清单
|
||||||
|
|
||||||
|
| 模块 | 功能 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户端 | 欢迎页、信息采集、面试初始化 | ✅ |
|
||||||
|
| 用户端 | 文字面试模式 | ✅ |
|
||||||
|
| 用户端 | 语音面试模式 | 🟡 RTC 正常,session_id 传递待优化 |
|
||||||
|
| 管理后台 | 登录、数据概览、面试列表、详情 | ✅ |
|
||||||
|
| 管理后台 | 骨架屏加载、阶段标签 | ✅ |
|
||||||
|
| 后端 | 文件上传、初始化、聊天、房间 API | ✅ |
|
||||||
|
| 后端 | 管理后台 API (通过 Workflow C) | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 最后更新:2026-01-21
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# AI Interview Backend App
|
||||||
55
backend/app/config.py
Normal file
55
backend/app/config.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
配置管理
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import List
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""应用配置"""
|
||||||
|
|
||||||
|
# 基础配置
|
||||||
|
DEBUG: bool = True
|
||||||
|
API_PORT: int = 8000
|
||||||
|
|
||||||
|
# CORS 配置 - 支持环境变量覆盖
|
||||||
|
CORS_ORIGINS: List[str] = [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://interview.test.ai.ireborn.com.cn",
|
||||||
|
"https://interview.test.ai.ireborn.com.cn"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Coze 配置
|
||||||
|
COZE_API_BASE: str = "https://api.coze.cn"
|
||||||
|
COZE_PAT_TOKEN: str = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
|
||||||
|
COZE_BOT_ID: str = "7595113005181386792"
|
||||||
|
|
||||||
|
# Coze 语音配置(从测试结果获取)
|
||||||
|
COZE_VOICE_ID: str = "7426725529589661723"
|
||||||
|
|
||||||
|
# 文件存储配置
|
||||||
|
UPLOAD_DIR: str = "uploads" # 本地上传文件存储目录(临时)
|
||||||
|
|
||||||
|
# 远程文件服务器配置(用于 Coze 工作流访问文件)
|
||||||
|
FILE_SERVER_UPLOAD_URL: str = "http://files.test.ai.ireborn.com.cn/upload.php"
|
||||||
|
FILE_SERVER_TOKEN: str = "" # PHP 上传接口的验证令牌
|
||||||
|
|
||||||
|
# 公网隧道 URL(已弃用,改用远程文件服务器)
|
||||||
|
TUNNEL_URL: str = ""
|
||||||
|
NGROK_URL: str = ""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""获取配置"""
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
# 直接实例化,每次导入时读取最新 .env
|
||||||
|
settings = Settings()
|
||||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Routers
|
||||||
239
backend/app/routers/admin.py
Normal file
239
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
后台管理 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
from loguru import logger
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
security = HTTPBasic()
|
||||||
|
|
||||||
|
# 管理员凭证
|
||||||
|
ADMIN_USERNAME = "admin"
|
||||||
|
ADMIN_PASSWORD = "admin"
|
||||||
|
|
||||||
|
# Coze 配置
|
||||||
|
COZE_PAT_TOKEN = settings.COZE_PAT_TOKEN
|
||||||
|
WORKFLOW_QUERY_ID = "7597376294612107318"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin(credentials: HTTPBasicCredentials = Depends(security)):
|
||||||
|
"""验证管理员凭证"""
|
||||||
|
is_username_correct = secrets.compare_digest(credentials.username, ADMIN_USERNAME)
|
||||||
|
is_password_correct = secrets.compare_digest(credentials.password, ADMIN_PASSWORD)
|
||||||
|
if not (is_username_correct and is_password_correct):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid credentials",
|
||||||
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
|
)
|
||||||
|
return credentials.username
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
"""登录请求"""
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApiResponse(BaseModel):
|
||||||
|
"""API 响应"""
|
||||||
|
code: int = 0
|
||||||
|
message: str = "success"
|
||||||
|
data: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=ApiResponse)
|
||||||
|
async def login(request: LoginRequest):
|
||||||
|
"""
|
||||||
|
管理员登录
|
||||||
|
"""
|
||||||
|
if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD:
|
||||||
|
return ApiResponse(data={"token": "admin_token", "username": ADMIN_USERNAME})
|
||||||
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_workflow(table: str, sql: str) -> dict:
|
||||||
|
"""
|
||||||
|
执行 Coze 工作流(通用 SQL 查询)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
table: 表名 - assessments / logs / config
|
||||||
|
sql: SQL 语句
|
||||||
|
"""
|
||||||
|
url = "https://api.coze.cn/v1/workflow/run"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建 JSON 格式的 input
|
||||||
|
input_data = json.dumps({
|
||||||
|
"table": table,
|
||||||
|
"sql": sql
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workflow_id": WORKFLOW_QUERY_ID,
|
||||||
|
"parameters": {
|
||||||
|
"input": input_data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Execute workflow: table={table}, sql={sql[:80]}...")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(url, json=payload, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Workflow response code: {data.get('code')}")
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
# 解析返回数据
|
||||||
|
result_str = data.get("data", "")
|
||||||
|
if result_str:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(result_str)
|
||||||
|
# 工作流返回格式: {"output": [...]}
|
||||||
|
if isinstance(parsed, dict) and "output" in parsed:
|
||||||
|
return parsed["output"]
|
||||||
|
return parsed
|
||||||
|
except:
|
||||||
|
return {"raw": result_str}
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
error_msg = data.get("msg", "Unknown error")
|
||||||
|
logger.error(f"Workflow error: {error_msg}")
|
||||||
|
raise HTTPException(status_code=500, detail=error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 面试评估 ====================
|
||||||
|
|
||||||
|
@router.get("/interviews", response_model=ApiResponse)
|
||||||
|
async def get_interviews(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取面试列表
|
||||||
|
"""
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
if session_id:
|
||||||
|
sql = f"""
|
||||||
|
SELECT session_id, candidate_name, bstudio_create_time,
|
||||||
|
sales_skill_score, sales_concept_score, competency_score,
|
||||||
|
final_score_report, current_stage
|
||||||
|
FROM ci_interview_assessments
|
||||||
|
WHERE session_id = '{session_id}'
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
sql = f"""
|
||||||
|
SELECT session_id, candidate_name, bstudio_create_time,
|
||||||
|
sales_skill_score, sales_concept_score, competency_score,
|
||||||
|
final_score_report, current_stage
|
||||||
|
FROM ci_interview_assessments
|
||||||
|
ORDER BY bstudio_create_time DESC
|
||||||
|
LIMIT {page_size} OFFSET {offset}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await execute_workflow("assessments", sql)
|
||||||
|
return ApiResponse(data=result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/interviews/{session_id}", response_model=ApiResponse)
|
||||||
|
async def get_interview_detail(session_id: str):
|
||||||
|
"""
|
||||||
|
获取面试详情(完整评估报告)
|
||||||
|
"""
|
||||||
|
sql = f"SELECT * FROM ci_interview_assessments WHERE session_id = '{session_id}'"
|
||||||
|
|
||||||
|
result = await execute_workflow("assessments", sql)
|
||||||
|
|
||||||
|
# 返回第一条记录
|
||||||
|
if isinstance(result, list) and len(result) > 0:
|
||||||
|
return ApiResponse(data=result[0])
|
||||||
|
|
||||||
|
return ApiResponse(data=result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/interviews/{session_id}/logs", response_model=ApiResponse)
|
||||||
|
async def get_interview_logs(session_id: str):
|
||||||
|
"""
|
||||||
|
获取面试对话记录
|
||||||
|
"""
|
||||||
|
sql = f"""
|
||||||
|
SELECT log_id, session_id, stage, round, ai_question, user_answer, log_type, bstudio_create_time
|
||||||
|
FROM ci_interview_logs
|
||||||
|
WHERE session_id = '{session_id}'
|
||||||
|
ORDER BY bstudio_create_time ASC
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await execute_workflow("logs", sql)
|
||||||
|
return ApiResponse(data=result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/interviews/{session_id}", response_model=ApiResponse)
|
||||||
|
async def delete_interview(session_id: str):
|
||||||
|
"""
|
||||||
|
删除面试记录(同时删除评估和日志)
|
||||||
|
"""
|
||||||
|
# 删除评估
|
||||||
|
sql1 = f"DELETE FROM ci_interview_assessments WHERE session_id = '{session_id}'"
|
||||||
|
await execute_workflow("assessments", sql1)
|
||||||
|
|
||||||
|
# 删除日志
|
||||||
|
sql2 = f"DELETE FROM ci_interview_logs WHERE session_id = '{session_id}'"
|
||||||
|
await execute_workflow("logs", sql2)
|
||||||
|
|
||||||
|
return ApiResponse(message="删除成功")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 业务配置 ====================
|
||||||
|
|
||||||
|
@router.get("/configs", response_model=ApiResponse)
|
||||||
|
async def get_configs(config_type: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
获取业务配置列表
|
||||||
|
"""
|
||||||
|
if config_type:
|
||||||
|
sql = f"""
|
||||||
|
SELECT config_id, config_type, item_name, content, bstudio_create_time
|
||||||
|
FROM ci_business_config
|
||||||
|
WHERE config_type = '{config_type}'
|
||||||
|
ORDER BY bstudio_create_time DESC
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
sql = """
|
||||||
|
SELECT config_id, config_type, item_name, content, bstudio_create_time
|
||||||
|
FROM ci_business_config
|
||||||
|
ORDER BY config_type, bstudio_create_time DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await execute_workflow("config", sql)
|
||||||
|
return ApiResponse(data=result)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 统计 ====================
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=ApiResponse)
|
||||||
|
async def get_stats():
|
||||||
|
"""
|
||||||
|
获取统计数据
|
||||||
|
"""
|
||||||
|
# 总面试数
|
||||||
|
sql_total = "SELECT COUNT(*) as total FROM ci_interview_assessments"
|
||||||
|
total_result = await execute_workflow("assessments", sql_total)
|
||||||
|
|
||||||
|
return ApiResponse(data={
|
||||||
|
"total_interviews": total_result
|
||||||
|
})
|
||||||
83
backend/app/routers/candidate.py
Normal file
83
backend/app/routers/candidate.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
候选人相关接口
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.schemas import ApiResponse, SubmitCandidateResponse
|
||||||
|
from app.services.coze_service import coze_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_id(name: str) -> str:
|
||||||
|
"""生成会话 ID"""
|
||||||
|
timestamp = int(time.time())
|
||||||
|
random_code = uuid.uuid4().hex[:6]
|
||||||
|
return f"SESS_{timestamp}_{name}_{random_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidates", response_model=ApiResponse)
|
||||||
|
async def submit_candidate(
|
||||||
|
name: str = Form(..., min_length=2, max_length=20, description="候选人姓名"),
|
||||||
|
resume: UploadFile = File(..., description="简历文件"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
提交候选人信息(上传简历)
|
||||||
|
|
||||||
|
- 上传简历到 Coze
|
||||||
|
- 生成 session_id
|
||||||
|
- 返回 session_id 和 file_id
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 验证文件类型
|
||||||
|
allowed_types = [
|
||||||
|
"application/pdf",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
]
|
||||||
|
if resume.content_type not in allowed_types:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="只支持 PDF、DOC、DOCX 格式的文件"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证文件大小(10MB)
|
||||||
|
content = await resume.read()
|
||||||
|
if len(content) > 10 * 1024 * 1024:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="文件大小不能超过 10MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 上传文件到 Coze
|
||||||
|
file_result = await coze_service.upload_file(content, resume.filename or "resume.pdf")
|
||||||
|
file_id = file_result.get("id")
|
||||||
|
|
||||||
|
if not file_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="文件上传失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成 session_id
|
||||||
|
session_id = generate_session_id(name)
|
||||||
|
|
||||||
|
logger.info(f"Candidate submitted: name={name}, session_id={session_id}, file_id={file_id}")
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=SubmitCandidateResponse(
|
||||||
|
sessionId=session_id,
|
||||||
|
fileId=file_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Submit candidate error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
48
backend/app/routers/chat.py
Normal file
48
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
文本对话接口(文字面试模式)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.schemas import ApiResponse, ChatRequest, ChatResponse
|
||||||
|
from app.services.coze_service import coze_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat", response_model=ApiResponse)
|
||||||
|
async def chat(request: ChatRequest):
|
||||||
|
"""
|
||||||
|
文本对话(文字面试模式)
|
||||||
|
|
||||||
|
使用 /v3/chat API 与 Chatflow 对话。
|
||||||
|
session_id 通过 user_id 传递,工作流从 USER_INPUT 获取。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Chat request: session_id={request.sessionId}, conv_id={request.conversationId}, message={request.message[:50]}...")
|
||||||
|
|
||||||
|
# 使用 /v3/chat API(Chatflow 对话)
|
||||||
|
# session_id 作为 user_id 传递,工作流从 {{USER_INPUT}} 获取
|
||||||
|
result = await coze_service.chat(
|
||||||
|
message=request.message,
|
||||||
|
user_id=request.sessionId, # session_id 作为 user_id
|
||||||
|
conversation_id=request.conversationId,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_data = ChatResponse(
|
||||||
|
reply=result.get("reply", ""),
|
||||||
|
conversationId=result.get("conversation_id", ""),
|
||||||
|
debugInfo=result.get("debug_info"),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Chat response: {response_data.reply[:50] if response_data.reply else 'empty'}...")
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=response_data
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Chat error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
35
backend/app/routers/files.py
Normal file
35
backend/app/routers/files.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
文件服务接口 - 提供文件下载
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}")
|
||||||
|
async def download_file(file_id: str):
|
||||||
|
"""
|
||||||
|
下载文件(供 Coze 工作流访问)
|
||||||
|
|
||||||
|
文件路径: uploads/{file_id}.pdf
|
||||||
|
"""
|
||||||
|
# 构建文件路径
|
||||||
|
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}.pdf")
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
logger.error(f"File not found: {file_path}")
|
||||||
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
|
|
||||||
|
logger.info(f"Serving file: {file_path}")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
filename=f"{file_id}.pdf",
|
||||||
|
media_type="application/pdf"
|
||||||
|
)
|
||||||
69
backend/app/routers/init.py
Normal file
69
backend/app/routers/init.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
面试初始化接口
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.schemas import ApiResponse
|
||||||
|
from app.services.coze_service import coze_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/init-interview", response_model=ApiResponse)
|
||||||
|
async def init_interview(
|
||||||
|
name: str = Form(..., description="候选人姓名"),
|
||||||
|
file: UploadFile = File(..., description="简历文件(PDF)"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化面试
|
||||||
|
|
||||||
|
1. 上传简历到 Coze
|
||||||
|
2. 获取文件临时 URL
|
||||||
|
3. 调用工作流 A(解析简历、创建记录)
|
||||||
|
4. 返回 session_id
|
||||||
|
|
||||||
|
后续使用 session_id 创建语音房间进行面试
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 验证文件类型
|
||||||
|
if not file.filename.lower().endswith('.pdf'):
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 PDF 格式")
|
||||||
|
|
||||||
|
# 读取文件内容
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
# 验证文件大小(最大 10MB)
|
||||||
|
if len(content) > 10 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
|
||||||
|
|
||||||
|
logger.info(f"Init interview: name={name}, file={file.filename}, size={len(content)} bytes")
|
||||||
|
|
||||||
|
# 执行完整的初始化流程(返回详细调试信息)
|
||||||
|
result = await coze_service.init_interview(
|
||||||
|
name=name,
|
||||||
|
file_content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = result.get("session_id", "")
|
||||||
|
debug_info = result.get("debug_info", {})
|
||||||
|
|
||||||
|
logger.info(f"Interview initialized: session_id={session_id}")
|
||||||
|
logger.info(f"Debug info: {debug_info}")
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"sessionId": session_id,
|
||||||
|
"name": name,
|
||||||
|
"debugInfo": debug_info,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Init interview error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
129
backend/app/routers/room.py
Normal file
129
backend/app/routers/room.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
房间相关接口
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.schemas import ApiResponse, CreateRoomRequest, CreateRoomResponse
|
||||||
|
from app.services.coze_service import coze_service
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_id() -> str:
|
||||||
|
"""生成用户 ID"""
|
||||||
|
return f"user_{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_id() -> str:
|
||||||
|
"""生成会话 ID"""
|
||||||
|
timestamp = int(time.time())
|
||||||
|
random_code = uuid.uuid4().hex[:6]
|
||||||
|
return f"SESS_{timestamp}_{random_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rooms", response_model=ApiResponse)
|
||||||
|
async def create_room(request: CreateRoomRequest):
|
||||||
|
"""
|
||||||
|
创建语音房间
|
||||||
|
|
||||||
|
方案A:前端不预先收集姓名和简历
|
||||||
|
- sessionId 和 fileId 都是可选的
|
||||||
|
- 如果没有传入 sessionId,后端自动生成
|
||||||
|
- 姓名和简历由 Coze 工作流在对话中收集
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 生成用户 ID
|
||||||
|
user_id = generate_user_id()
|
||||||
|
|
||||||
|
# 如果没有传入 sessionId,自动生成
|
||||||
|
session_id = request.sessionId or generate_session_id()
|
||||||
|
|
||||||
|
# 调用 Coze API 创建语音房间
|
||||||
|
# Coze 会自动让 Bot 加入房间,并返回 RTC 连接信息
|
||||||
|
room_data = await coze_service.create_audio_room(
|
||||||
|
user_id=user_id,
|
||||||
|
file_id=request.fileId, # 可能是 None
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Coze 返回的字段映射
|
||||||
|
# Coze: app_id, room_id, token, uid
|
||||||
|
# 前端期望: appId, roomId, token, userId, sessionId
|
||||||
|
response_data = CreateRoomResponse(
|
||||||
|
appId=room_data.get("app_id", ""),
|
||||||
|
roomId=room_data.get("room_id", ""),
|
||||||
|
token=room_data.get("token", ""),
|
||||||
|
userId=room_data.get("uid", user_id),
|
||||||
|
sessionId=session_id, # 返回 sessionId 给前端
|
||||||
|
debugInfo=room_data.get("debug_info"), # 调试信息
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Room created: session_id={session_id}, room_id={response_data.roomId}")
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=response_data
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Create room error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/interviews/{session_id}/end", response_model=ApiResponse)
|
||||||
|
async def end_interview(session_id: str):
|
||||||
|
"""
|
||||||
|
结束面试
|
||||||
|
|
||||||
|
- 通知后端面试已结束
|
||||||
|
- 可以在这里添加后续处理逻辑
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Interview ended: session_id={session_id}")
|
||||||
|
|
||||||
|
# TODO: 可以在这里添加后续处理逻辑
|
||||||
|
# 例如:更新状态、发送通知等
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data={"success": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"End interview error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/coze-config", response_model=ApiResponse)
|
||||||
|
async def get_coze_config():
|
||||||
|
"""
|
||||||
|
获取 Coze Realtime SDK 配置
|
||||||
|
|
||||||
|
返回前端需要的配置信息,用于直接连接 Coze Realtime
|
||||||
|
注意:这种方式会暴露 PAT Token 到浏览器,仅用于开发/测试
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = {
|
||||||
|
"accessToken": settings.COZE_PAT_TOKEN,
|
||||||
|
"botId": settings.COZE_BOT_ID,
|
||||||
|
"voiceId": settings.COZE_VOICE_ID,
|
||||||
|
"connectorId": "1024", # 固定值
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Coze config requested: bot_id={settings.COZE_BOT_ID}")
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=config
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get coze config error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
97
backend/app/routers/upload.py
Normal file
97
backend/app/routers/upload.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
文件上传接口
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, UploadFile, File, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.schemas import ApiResponse
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# 上传目录
|
||||||
|
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads")
|
||||||
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=ApiResponse)
|
||||||
|
async def upload_file(request: Request, file: UploadFile = File(...)):
|
||||||
|
"""
|
||||||
|
上传文件并返回公开访问链接
|
||||||
|
|
||||||
|
- 接收前端上传的文件(如简历 PDF)
|
||||||
|
- 保存到本地并生成公开链接
|
||||||
|
- 返回文件链接供 Coze 工作流使用
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 验证文件类型
|
||||||
|
if not file.filename.lower().endswith('.pdf'):
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 PDF 格式")
|
||||||
|
|
||||||
|
# 读取文件内容
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
# 验证文件大小(最大 10MB)
|
||||||
|
if len(content) > 10 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
|
||||||
|
|
||||||
|
# 生成唯一文件名
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
unique_id = uuid.uuid4().hex[:8]
|
||||||
|
safe_filename = f"{timestamp}_{unique_id}.pdf"
|
||||||
|
|
||||||
|
# 保存文件
|
||||||
|
file_path = os.path.join(UPLOAD_DIR, safe_filename)
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
logger.info(f"File saved: {file_path}, size: {len(content)} bytes")
|
||||||
|
|
||||||
|
# 生成公开访问链接
|
||||||
|
# 从请求中获取 host
|
||||||
|
host = request.headers.get("host", "localhost:8000")
|
||||||
|
scheme = request.headers.get("x-forwarded-proto", "http")
|
||||||
|
file_url = f"{scheme}://{host}/api/files/{safe_filename}"
|
||||||
|
|
||||||
|
logger.info(f"File URL: {file_url}")
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"fileUrl": file_url,
|
||||||
|
"fileName": file.filename,
|
||||||
|
"fileSize": len(content),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Upload error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{filename}")
|
||||||
|
async def get_file(filename: str):
|
||||||
|
"""
|
||||||
|
获取上传的文件(供 Coze 工作流访问)
|
||||||
|
"""
|
||||||
|
# 安全检查:防止路径遍历
|
||||||
|
if ".." in filename or "/" in filename or "\\" in filename:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
|
file_path = os.path.join(UPLOAD_DIR, filename)
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
file_path,
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=filename
|
||||||
|
)
|
||||||
100
backend/app/schemas.py
Normal file
100
backend/app/schemas.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Pydantic 数据模型
|
||||||
|
"""
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 通用响应 ============
|
||||||
|
|
||||||
|
class ApiResponse(BaseModel):
|
||||||
|
"""统一 API 响应格式"""
|
||||||
|
code: int = 0
|
||||||
|
message: str = "success"
|
||||||
|
data: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 候选人相关 ============
|
||||||
|
|
||||||
|
class SubmitCandidateResponse(BaseModel):
|
||||||
|
"""提交候选人信息响应"""
|
||||||
|
sessionId: str
|
||||||
|
fileId: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRoomRequest(BaseModel):
|
||||||
|
"""创建房间请求"""
|
||||||
|
sessionId: Optional[str] = None # 方案A:可选,由后端生成
|
||||||
|
fileId: Optional[str] = None # 方案A:可选,由工作流收集
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRoomResponse(BaseModel):
|
||||||
|
"""创建房间响应"""
|
||||||
|
roomId: str
|
||||||
|
token: str
|
||||||
|
appId: str
|
||||||
|
userId: str
|
||||||
|
sessionId: Optional[str] = None # 返回给前端用于后续操作
|
||||||
|
debugInfo: Optional[Any] = None # 调试信息(语音模式)
|
||||||
|
|
||||||
|
|
||||||
|
class EndInterviewResponse(BaseModel):
|
||||||
|
"""结束面试响应"""
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
"""文本对话请求(模拟语音)"""
|
||||||
|
sessionId: str
|
||||||
|
message: str
|
||||||
|
conversationId: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
"""文本对话响应"""
|
||||||
|
reply: str
|
||||||
|
conversationId: str
|
||||||
|
debugInfo: Optional[Any] = None # 调试信息(节点状态、消息列表等)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 管理后台相关 ============
|
||||||
|
|
||||||
|
class CandidateScores(BaseModel):
|
||||||
|
"""候选人评分"""
|
||||||
|
salesSkill: int = Field(0, ge=0, le=100)
|
||||||
|
salesMindset: int = Field(0, ge=0, le=100)
|
||||||
|
quality: int = Field(0, ge=0, le=100)
|
||||||
|
motivation: int = Field(0, ge=0, le=100)
|
||||||
|
total: float = Field(0, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateListItem(BaseModel):
|
||||||
|
"""候选人列表项"""
|
||||||
|
sessionId: str
|
||||||
|
name: str
|
||||||
|
status: str # pending, ongoing, completed
|
||||||
|
score: Optional[float] = None
|
||||||
|
createdAt: str
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateDetail(BaseModel):
|
||||||
|
"""候选人详情"""
|
||||||
|
sessionId: str
|
||||||
|
name: str
|
||||||
|
resume: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
currentStage: int = 0
|
||||||
|
scores: Optional[CandidateScores] = None
|
||||||
|
analysis: Optional[str] = None
|
||||||
|
interviewLog: Optional[str] = None
|
||||||
|
createdAt: str
|
||||||
|
completedAt: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateListResponse(BaseModel):
|
||||||
|
"""候选人列表响应"""
|
||||||
|
list: List[CandidateListItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
pageSize: int
|
||||||
4
backend/app/services/__init__.py
Normal file
4
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Services
|
||||||
|
from .coze_service import CozeService, coze_service
|
||||||
|
|
||||||
|
__all__ = ["CozeService", "coze_service"]
|
||||||
839
backend/app/services/coze_service.py
Normal file
839
backend/app/services/coze_service.py
Normal file
@@ -0,0 +1,839 @@
|
|||||||
|
"""
|
||||||
|
Coze API 服务封装
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import httpx
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class CozeService:
|
||||||
|
"""Coze API 服务"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = settings.COZE_API_BASE
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {settings.COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
self.bot_id = settings.COZE_BOT_ID
|
||||||
|
|
||||||
|
# 工作流 A 的 ID(初始化工作流)
|
||||||
|
INIT_WORKFLOW_ID = "7597357422713798710"
|
||||||
|
|
||||||
|
# 工作流 B 的 ID(面试工作流)
|
||||||
|
INTERVIEW_WORKFLOW_ID = "7595077233002840079"
|
||||||
|
|
||||||
|
async def upload_file(self, file_content: bytes, filename: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
上传文件到 Coze
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_content: 文件内容
|
||||||
|
filename: 文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"id": "file_xxx", ...}
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/v1/files/upload"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
files = {"file": (filename, file_content)}
|
||||||
|
headers = {"Authorization": f"Bearer {settings.COZE_PAT_TOKEN}"}
|
||||||
|
|
||||||
|
response = await client.post(url, files=files, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"File uploaded: {data}")
|
||||||
|
|
||||||
|
if "data" in data:
|
||||||
|
return data["data"]
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_file_url(self, file_id: str) -> str:
|
||||||
|
"""
|
||||||
|
获取 Coze 文件的临时下载链接
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_id: 文件 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
文件的临时下载 URL
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/v1/files/retrieve"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
params={"file_id": file_id},
|
||||||
|
headers=self.headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"File retrieve response: {data}")
|
||||||
|
|
||||||
|
# 返回文件的临时 URL
|
||||||
|
file_info = data.get("data", {})
|
||||||
|
file_url = file_info.get("url", "")
|
||||||
|
|
||||||
|
if not file_url:
|
||||||
|
raise ValueError(f"Failed to get file URL for file_id: {file_id}")
|
||||||
|
|
||||||
|
return file_url
|
||||||
|
|
||||||
|
async def run_init_workflow(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
file_url: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
执行初始化工作流(工作流A)
|
||||||
|
|
||||||
|
- 上传简历、解析、创建数据库记录
|
||||||
|
- 返回 session_id 和调试信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 候选人姓名
|
||||||
|
file_url: 简历文件链接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"session_id": "xxx", "raw_response": {...}, "parsed_data": {...}, "debug_url": "..."}
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
url = f"{self.base_url}/v1/workflow/run"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workflow_id": self.INIT_WORKFLOW_ID,
|
||||||
|
"parameters": {
|
||||||
|
"name": name,
|
||||||
|
"file_url": file_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Running init workflow with payload: {payload}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(url, json=payload, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Init workflow response: {data}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"session_id": "",
|
||||||
|
"raw_response": data,
|
||||||
|
"parsed_data": None,
|
||||||
|
"debug_url": data.get("debug_url", ""),
|
||||||
|
"execute_id": data.get("execute_id", ""),
|
||||||
|
"code": data.get("code"),
|
||||||
|
"msg": data.get("msg", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
import json
|
||||||
|
|
||||||
|
# execute_id 在顶层
|
||||||
|
execute_id = data.get("execute_id", "")
|
||||||
|
# data 字段是工作流输出(JSON 字符串)
|
||||||
|
output_str = data.get("data", "")
|
||||||
|
|
||||||
|
result["execute_id"] = execute_id
|
||||||
|
result["output_str"] = output_str
|
||||||
|
|
||||||
|
logger.info(f"Workflow execute_id: {execute_id}")
|
||||||
|
logger.info(f"Workflow output_str: {output_str}")
|
||||||
|
|
||||||
|
# 构建调试链接
|
||||||
|
result["debug_url"] = f"https://www.coze.cn/work_flow?execute_id={execute_id}&space_id=7516832346776780836&workflow_id={self.INIT_WORKFLOW_ID}&execute_mode=2"
|
||||||
|
|
||||||
|
# 解析工作流输出
|
||||||
|
session_id = None
|
||||||
|
output_data = None
|
||||||
|
if output_str:
|
||||||
|
try:
|
||||||
|
output_data = json.loads(output_str)
|
||||||
|
result["parsed_data"] = output_data
|
||||||
|
logger.info(f"Workflow output_data: {output_data}")
|
||||||
|
|
||||||
|
# 尝试从不同格式中提取 session_id
|
||||||
|
if isinstance(output_data, dict):
|
||||||
|
# 格式1: {"session_id": "xxx"}
|
||||||
|
session_id = output_data.get("session_id")
|
||||||
|
|
||||||
|
# 格式2: {"data": "SESS_xxx"} - data 直接是 session_id 字符串
|
||||||
|
if not session_id and "data" in output_data:
|
||||||
|
inner_data = output_data.get("data")
|
||||||
|
# 如果 data 是字符串且以 SESS_ 开头,直接使用
|
||||||
|
if isinstance(inner_data, str) and inner_data.startswith("SESS_"):
|
||||||
|
session_id = inner_data
|
||||||
|
elif isinstance(inner_data, str):
|
||||||
|
# 尝试解析为 JSON
|
||||||
|
try:
|
||||||
|
inner_data = json.loads(inner_data)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if isinstance(inner_data, dict):
|
||||||
|
session_id = inner_data.get("session_id")
|
||||||
|
elif isinstance(inner_data, list) and len(inner_data) > 0:
|
||||||
|
session_id = inner_data[0].get("session_id") if isinstance(inner_data[0], dict) else None
|
||||||
|
elif isinstance(output_data, list) and len(output_data) > 0:
|
||||||
|
# 格式3: [{"session_id": "xxx"}]
|
||||||
|
if isinstance(output_data[0], dict):
|
||||||
|
session_id = output_data[0].get("session_id")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Failed to parse workflow output: {e}")
|
||||||
|
result["parse_error"] = str(e)
|
||||||
|
|
||||||
|
# 如果没有 session_id,使用 execute_id 作为替代
|
||||||
|
if not session_id:
|
||||||
|
logger.warning(f"No session_id in workflow output, using execute_id as session_id")
|
||||||
|
session_id = f"WF_{execute_id}" if execute_id else f"SESS_{int(time.time())}"
|
||||||
|
result["session_id_source"] = "execute_id_fallback"
|
||||||
|
else:
|
||||||
|
result["session_id_source"] = "workflow_output"
|
||||||
|
|
||||||
|
result["session_id"] = session_id
|
||||||
|
logger.info(f"Final session_id: {session_id}")
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
error_msg = data.get("msg", "Unknown error")
|
||||||
|
result["error"] = error_msg
|
||||||
|
raise ValueError(f"Workflow execution failed: {error_msg}")
|
||||||
|
|
||||||
|
async def _wait_for_workflow_result(
|
||||||
|
self,
|
||||||
|
execute_id: str,
|
||||||
|
max_retries: int = 60,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
等待工作流执行完成并获取结果
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
url = f"{self.base_url}/v1/workflow/run_histories"
|
||||||
|
|
||||||
|
for i in range(max_retries):
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
params={"execute_id": execute_id},
|
||||||
|
headers=self.headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Workflow status [{i}]: {data}")
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
result_data = data.get("data", {})
|
||||||
|
status = result_data.get("status", "")
|
||||||
|
|
||||||
|
if status == "Success":
|
||||||
|
output = result_data.get("output", "")
|
||||||
|
# 解析输出获取 session_id
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
output_data = json.loads(output)
|
||||||
|
return output_data.get("session_id", output)
|
||||||
|
except:
|
||||||
|
return output
|
||||||
|
elif status == "Failed":
|
||||||
|
raise ValueError(f"Workflow failed: {result_data.get('error', 'Unknown')}")
|
||||||
|
# Running 状态继续等待
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
raise TimeoutError("Workflow execution timeout")
|
||||||
|
|
||||||
|
async def init_interview(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
file_content: bytes,
|
||||||
|
filename: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
完整的面试初始化流程
|
||||||
|
|
||||||
|
1. 上传文件到远程服务器
|
||||||
|
2. 获取公网可访问的 URL
|
||||||
|
3. 执行初始化工作流
|
||||||
|
4. 返回 session_id 和调试信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 候选人姓名
|
||||||
|
file_content: 简历文件内容
|
||||||
|
filename: 文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"session_id": "xxx", "debug_info": {...}}
|
||||||
|
"""
|
||||||
|
debug_info = {
|
||||||
|
"steps": [],
|
||||||
|
"timestamps": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 上传文件到远程服务器
|
||||||
|
debug_info["steps"].append("Step 1: Uploading file to remote server")
|
||||||
|
debug_info["file_size"] = len(file_content)
|
||||||
|
|
||||||
|
logger.info(f"Step 1: Uploading file to remote server ({len(file_content)} bytes)...")
|
||||||
|
|
||||||
|
# 检查配置
|
||||||
|
if not settings.FILE_SERVER_TOKEN:
|
||||||
|
raise ValueError("FILE_SERVER_TOKEN is not configured. Please set FILE_SERVER_TOKEN in .env file.")
|
||||||
|
|
||||||
|
# 调用远程 PHP 上传接口
|
||||||
|
upload_url = settings.FILE_SERVER_UPLOAD_URL
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
files = {"file": (filename, file_content, "application/pdf")}
|
||||||
|
data = {"token": settings.FILE_SERVER_TOKEN}
|
||||||
|
|
||||||
|
response = await client.post(upload_url, files=files, data=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
upload_result = response.json()
|
||||||
|
logger.info(f"Upload response: {upload_result}")
|
||||||
|
|
||||||
|
if upload_result.get("code") != 0:
|
||||||
|
error_msg = upload_result.get("error", "Unknown upload error")
|
||||||
|
raise ValueError(f"File upload failed: {error_msg}")
|
||||||
|
|
||||||
|
file_url = upload_result.get("url", "")
|
||||||
|
file_id = upload_result.get("file_id", "")
|
||||||
|
|
||||||
|
debug_info["steps"].append("File uploaded successfully")
|
||||||
|
debug_info["file_id"] = file_id
|
||||||
|
debug_info["file_url"] = file_url
|
||||||
|
debug_info["upload_response"] = upload_result
|
||||||
|
|
||||||
|
logger.info(f"Step 1 completed: file_url={file_url}")
|
||||||
|
|
||||||
|
# 2. 执行初始化工作流
|
||||||
|
logger.info(f"Step 2: Running init workflow with name={name}, file_url={file_url}...")
|
||||||
|
debug_info["steps"].append("Step 2: Running init workflow")
|
||||||
|
debug_info["workflow_input"] = {"name": name, "file_url": file_url}
|
||||||
|
|
||||||
|
workflow_result = await self.run_init_workflow(name, file_url)
|
||||||
|
|
||||||
|
# workflow_result 现在返回更多信息
|
||||||
|
session_id = workflow_result.get("session_id", "")
|
||||||
|
debug_info["steps"].append("Workflow completed")
|
||||||
|
debug_info["workflow_response"] = workflow_result.get("raw_response")
|
||||||
|
debug_info["workflow_data"] = workflow_result.get("parsed_data")
|
||||||
|
debug_info["debug_url"] = workflow_result.get("debug_url")
|
||||||
|
|
||||||
|
logger.info(f"Init workflow completed, session_id: {session_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"debug_info": debug_info
|
||||||
|
}
|
||||||
|
|
||||||
|
async def create_audio_room(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
file_id: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
创建语音房间,让 Bot 加入
|
||||||
|
|
||||||
|
Coze API 会返回完整的 RTC 连接信息:
|
||||||
|
- app_id: 火山引擎 RTC App ID
|
||||||
|
- room_id: 房间 ID
|
||||||
|
- token: RTC Token
|
||||||
|
- uid: 用户 ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户 ID
|
||||||
|
file_id: 简历文件 ID(可选)
|
||||||
|
session_id: 会话 ID(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RTC 连接信息
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/v1/audio/rooms"
|
||||||
|
|
||||||
|
# 将 session_id 作为 user_id 传递,这样工作流可以从 sys_var.user_id 获取
|
||||||
|
# 如果有 session_id,用它作为 user_id;否则用原始 user_id
|
||||||
|
actual_user_id = session_id if session_id else user_id
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"user_id": actual_user_id, # session_id 作为 user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# 添加语音 ID
|
||||||
|
if settings.COZE_VOICE_ID:
|
||||||
|
payload["voice_id"] = settings.COZE_VOICE_ID
|
||||||
|
|
||||||
|
# ========== 尝试通过多种方式传递 session_id ==========
|
||||||
|
|
||||||
|
# 方式 1: parameters(类似工作流 API)
|
||||||
|
if session_id:
|
||||||
|
payload["parameters"] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 方式 2: custom_variables(类似对话 API)
|
||||||
|
if session_id:
|
||||||
|
payload["custom_variables"] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 方式 3: connector_id / extra_info(备用)
|
||||||
|
if session_id:
|
||||||
|
payload["extra_info"] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 方式 4: config 对象
|
||||||
|
config = {}
|
||||||
|
if file_id:
|
||||||
|
config["input_file_id"] = file_id
|
||||||
|
if session_id:
|
||||||
|
config["session_id"] = session_id
|
||||||
|
config["parameters"] = {"session_id": session_id}
|
||||||
|
if config:
|
||||||
|
payload["config"] = config
|
||||||
|
|
||||||
|
logger.info(f"Creating audio room with payload: {payload}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(url, json=payload, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Audio room created: {data}")
|
||||||
|
|
||||||
|
result = data.get("data", data)
|
||||||
|
|
||||||
|
# 添加调试信息(避免循环引用,不包含 data 字段)
|
||||||
|
result["debug_info"] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"actual_user_id": actual_user_id,
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"request_payload": payload, # 完整的请求参数(包括所有尝试的字段)
|
||||||
|
"coze_code": data.get("code"),
|
||||||
|
"coze_msg": data.get("msg"),
|
||||||
|
"coze_logid": data.get("detail", {}).get("logid"),
|
||||||
|
"coze_bot_url": f"https://www.coze.cn/space/7516832346776780836/bot/{self.bot_id}",
|
||||||
|
"search_hint": f"工作流可通过 {{{{sys_var.user_id}}}} 获取 session_id: {actual_user_id}",
|
||||||
|
"tried_methods": [
|
||||||
|
"user_id (as session_id)",
|
||||||
|
"parameters.session_id",
|
||||||
|
"custom_variables.session_id",
|
||||||
|
"extra_info.session_id",
|
||||||
|
"config.session_id",
|
||||||
|
"config.parameters.session_id",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def chat_via_workflow(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
message: str,
|
||||||
|
workflow_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
通过工作流 API 进行文字对话(推荐用于文字面试模式)
|
||||||
|
|
||||||
|
使用 /v1/workflow/run API 直接调用工作流 B,
|
||||||
|
可以正确传递 session_id 等必填参数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID(来自工作流 A)
|
||||||
|
message: 用户消息
|
||||||
|
workflow_id: 工作流 ID(可选,默认使用 INTERVIEW_WORKFLOW_ID)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"reply": "AI回复"}
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
wf_id = workflow_id or self.INTERVIEW_WORKFLOW_ID
|
||||||
|
|
||||||
|
if not wf_id:
|
||||||
|
raise ValueError("工作流 B 的 ID 未配置,请设置 INTERVIEW_WORKFLOW_ID")
|
||||||
|
|
||||||
|
url = f"{self.base_url}/v1/workflow/run"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workflow_id": wf_id,
|
||||||
|
"parameters": {
|
||||||
|
"session_id": session_id,
|
||||||
|
"USER_INPUT": message, # 用户输入
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Workflow chat request: {payload}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(url, json=payload, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Workflow chat response: {data}")
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
# 解析工作流输出
|
||||||
|
output_str = data.get("data", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if output_str:
|
||||||
|
output_data = json.loads(output_str)
|
||||||
|
# 尝试提取回复内容
|
||||||
|
if isinstance(output_data, dict):
|
||||||
|
reply = output_data.get("reply") or output_data.get("output") or output_data.get("data", "")
|
||||||
|
if isinstance(reply, str):
|
||||||
|
return {"reply": reply}
|
||||||
|
elif isinstance(reply, dict):
|
||||||
|
return {"reply": reply.get("content", str(reply))}
|
||||||
|
elif isinstance(output_data, str):
|
||||||
|
return {"reply": output_data}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# 如果不是 JSON,直接返回原始字符串
|
||||||
|
return {"reply": output_str}
|
||||||
|
|
||||||
|
return {"reply": output_str or "工作流执行完成"}
|
||||||
|
else:
|
||||||
|
error_msg = data.get("msg", "Unknown error")
|
||||||
|
raise ValueError(f"Workflow execution failed: {error_msg}")
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
文本对话 - 使用 /v1/workflows/chat 专用接口
|
||||||
|
|
||||||
|
这个接口专门用于 Chatflow,支持:
|
||||||
|
1. 通过 parameters 传递自定义参数(如 session_id)
|
||||||
|
2. 正确维持问答节点的对话状态
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 用户消息
|
||||||
|
user_id: session_id(面试会话 ID)
|
||||||
|
conversation_id: 对话 ID(用于多轮对话)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"reply": "AI回复", "conversation_id": "xxx"}
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# 尝试使用 /v1/workflows/chat 专用接口
|
||||||
|
# 工作流 B 的 ID(Chatflow)
|
||||||
|
workflow_id = "7595077233002840079"
|
||||||
|
|
||||||
|
url = f"{self.base_url}/v1/workflows/chat"
|
||||||
|
|
||||||
|
# 构建 payload
|
||||||
|
payload = {
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"stream": False,
|
||||||
|
"additional_messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": message,
|
||||||
|
"content_type": "text",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
# 通过 parameters 显式传递 session_id
|
||||||
|
"parameters": {
|
||||||
|
"session_id": user_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 传递 conversation_id 延续对话
|
||||||
|
if conversation_id:
|
||||||
|
payload["conversation_id"] = conversation_id
|
||||||
|
|
||||||
|
logger.info(f"[Workflows/Chat] request: session_id={user_id}, conv_id={conversation_id}, message={message[:50]}...")
|
||||||
|
logger.debug(f"[Workflows/Chat] payload: {payload}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(url, json=payload, headers=self.headers)
|
||||||
|
|
||||||
|
# 检查响应状态
|
||||||
|
logger.info(f"[Workflows/Chat] status: {response.status_code}, text: {response.text[:200] if response.text else 'empty'}")
|
||||||
|
|
||||||
|
if response.status_code != 200 or not response.text:
|
||||||
|
logger.warning(f"[Workflows/Chat] failed (status={response.status_code}), falling back to /v3/chat")
|
||||||
|
return await self._chat_v3(message, user_id, conversation_id)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"[Workflows/Chat] response: code={data.get('code')}, msg={data.get('msg', '')}")
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
# 如果 /v1/workflows/chat 失败,回退到 /v3/chat
|
||||||
|
logger.warning(f"[Workflows/Chat] API error, falling back to /v3/chat")
|
||||||
|
return await self._chat_v3(message, user_id, conversation_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Workflows/Chat] exception: {e}, falling back to /v3/chat")
|
||||||
|
return await self._chat_v3(message, user_id, conversation_id)
|
||||||
|
|
||||||
|
# 解析响应
|
||||||
|
chat_data = data.get("data", {})
|
||||||
|
conv_id = chat_data.get("conversation_id", "")
|
||||||
|
chat_id = chat_data.get("id", "")
|
||||||
|
|
||||||
|
logger.info(f"[Workflows/Chat] started: conv_id={conv_id}, chat_id={chat_id}")
|
||||||
|
|
||||||
|
# 轮询等待回复完成
|
||||||
|
if chat_id and conv_id:
|
||||||
|
result = await self._wait_for_reply(conv_id, chat_id)
|
||||||
|
return {
|
||||||
|
"reply": result.get("reply", ""),
|
||||||
|
"conversation_id": conv_id,
|
||||||
|
"debug_info": result.get("debug_info", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reply": "抱歉,我没有理解您的意思,请再说一次。",
|
||||||
|
"conversation_id": conversation_id or "",
|
||||||
|
"debug_info": {"error": "Workflows/Chat API error"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _chat_v3(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
备用方法:使用 /v3/chat API
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/v3/chat"
|
||||||
|
|
||||||
|
# 构建消息内容:嵌入 session_id
|
||||||
|
if not conversation_id:
|
||||||
|
content = f"[SESSION:{user_id}]\n{message}"
|
||||||
|
else:
|
||||||
|
content = message
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"stream": False,
|
||||||
|
"auto_save_history": True,
|
||||||
|
"additional_messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
"content_type": "text",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if conversation_id:
|
||||||
|
payload["conversation_id"] = conversation_id
|
||||||
|
|
||||||
|
logger.info(f"[v3/chat] request: user_id={user_id}, conv_id={conversation_id}")
|
||||||
|
logger.debug(f"[v3/chat] payload: {payload}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(url, json=payload, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"[v3/chat] response: code={data.get('code')}")
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
chat_data = data.get("data", {})
|
||||||
|
conv_id = chat_data.get("conversation_id", "")
|
||||||
|
chat_id = chat_data.get("id", "")
|
||||||
|
|
||||||
|
logger.info(f"[v3/chat] started: conv_id={conv_id}, chat_id={chat_id}")
|
||||||
|
|
||||||
|
if chat_id and conv_id:
|
||||||
|
result = await self._wait_for_reply(conv_id, chat_id)
|
||||||
|
# 构建 Coze 后台查询链接
|
||||||
|
coze_debug_url = f"https://www.coze.cn/space/7516832346776780836/bot/{self.bot_id}"
|
||||||
|
debug_info = result.get("debug_info", {})
|
||||||
|
debug_info.update({
|
||||||
|
"conversation_id": conv_id,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"session_id": user_id,
|
||||||
|
"coze_bot_url": coze_debug_url,
|
||||||
|
"search_hint": f"在 Coze 后台搜索 conversation_id: {conv_id} 或 user_id: {user_id}",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"reply": result.get("reply", ""),
|
||||||
|
"conversation_id": conv_id,
|
||||||
|
"debug_info": debug_info,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = data.get("msg", "Unknown error")
|
||||||
|
logger.error(f"[v3/chat] API error: {error_msg}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reply": "抱歉,我没有理解您的意思,请再说一次。",
|
||||||
|
"conversation_id": conversation_id or "",
|
||||||
|
"debug_info": {"error": "v3/chat API error"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _wait_for_reply(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
max_retries: int = 30,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
等待 AI 回复完成
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"reply": str, "debug_info": dict}
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
url = f"{self.base_url}/v3/chat/retrieve"
|
||||||
|
debug_info = {
|
||||||
|
"status_history": [],
|
||||||
|
"messages": [],
|
||||||
|
"raw_responses": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in range(max_retries):
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
params={
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
},
|
||||||
|
headers=self.headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# 记录原始响应(用于调试)
|
||||||
|
debug_info["raw_responses"].append({
|
||||||
|
"iteration": i,
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
chat_data = data.get("data", {})
|
||||||
|
status = chat_data.get("status", "")
|
||||||
|
|
||||||
|
# 记录状态历史
|
||||||
|
status_info = {
|
||||||
|
"iteration": i,
|
||||||
|
"status": status,
|
||||||
|
"required_action": chat_data.get("required_action"),
|
||||||
|
}
|
||||||
|
debug_info["status_history"].append(status_info)
|
||||||
|
|
||||||
|
logger.info(f"Chat status [{i}]: {status}")
|
||||||
|
|
||||||
|
# 打印详细的节点信息
|
||||||
|
if chat_data.get("required_action"):
|
||||||
|
action = chat_data.get("required_action")
|
||||||
|
logger.info(f"🔔 Required action: {json.dumps(action, ensure_ascii=False, indent=2)}")
|
||||||
|
|
||||||
|
if status == "completed":
|
||||||
|
# 获取消息列表
|
||||||
|
messages = await self._get_messages(conversation_id, chat_id)
|
||||||
|
debug_info["messages"] = messages
|
||||||
|
|
||||||
|
# 找到 AI 的回复
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if msg.get("role") == "assistant" and msg.get("type") == "answer":
|
||||||
|
return {
|
||||||
|
"reply": msg.get("content", ""),
|
||||||
|
"debug_info": debug_info,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"reply": "面试官正在思考...",
|
||||||
|
"debug_info": debug_info,
|
||||||
|
}
|
||||||
|
elif status == "failed":
|
||||||
|
return {
|
||||||
|
"reply": f"抱歉,出现了一些问题:{chat_data.get('last_error', {}).get('msg', '未知错误')}",
|
||||||
|
"debug_info": debug_info,
|
||||||
|
}
|
||||||
|
elif status == "requires_action":
|
||||||
|
# 工作流需要用户输入(question 节点或文件上传节点)
|
||||||
|
messages = await self._get_messages(conversation_id, chat_id)
|
||||||
|
debug_info["messages"] = messages
|
||||||
|
|
||||||
|
# 打印所有消息用于调试
|
||||||
|
logger.info(f"📨 Messages ({len(messages)} total):")
|
||||||
|
for idx, msg in enumerate(messages):
|
||||||
|
msg_type = msg.get("type", "unknown")
|
||||||
|
msg_role = msg.get("role", "unknown")
|
||||||
|
msg_content = msg.get("content", "")[:200]
|
||||||
|
logger.info(f" [{idx}] {msg_role}/{msg_type}: {msg_content}")
|
||||||
|
|
||||||
|
# 检查是否有文件上传请求
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get("type") == "tool_call":
|
||||||
|
logger.info(f"🔧 Tool call detected: {msg.get('content', '')}")
|
||||||
|
|
||||||
|
# 返回 AI 的问题
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if msg.get("role") == "assistant" and msg.get("type") == "answer":
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if content:
|
||||||
|
return {
|
||||||
|
"reply": content,
|
||||||
|
"debug_info": debug_info,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"reply": "请回答上面的问题...",
|
||||||
|
"debug_info": debug_info,
|
||||||
|
}
|
||||||
|
# 其他状态(in_progress, created)继续等待
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reply": "响应超时,请重试。",
|
||||||
|
"debug_info": debug_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_messages(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
获取对话消息列表
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/v3/chat/message/list"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
params={
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
},
|
||||||
|
headers=self.headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Messages response: {data}")
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# 创建单例
|
||||||
|
coze_service = CozeService()
|
||||||
40
backend/check_table.py
Normal file
40
backend/check_table.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""查询表现有数据的结构"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
|
||||||
|
COZE_API_BASE = "https://api.coze.cn"
|
||||||
|
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
|
||||||
|
WORKFLOW_ID = "7597376294612107318"
|
||||||
|
|
||||||
|
async def query(table: str, sql: str):
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
input_json = json.dumps({"table": table, "sql": sql}, ensure_ascii=False)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{COZE_API_BASE}/v1/workflow/run",
|
||||||
|
headers=headers,
|
||||||
|
json={"workflow_id": WORKFLOW_ID, "parameters": {"input": input_json}}
|
||||||
|
)
|
||||||
|
result = response.json()
|
||||||
|
print(f"\n{table}:")
|
||||||
|
print(f" code: {result.get('code')}")
|
||||||
|
if result.get('data'):
|
||||||
|
data = json.loads(result.get('data', '{}'))
|
||||||
|
output = data.get('output', [])
|
||||||
|
if output and len(output) > 0:
|
||||||
|
print(f" 列名: {list(output[0].keys())}")
|
||||||
|
else:
|
||||||
|
print(f" 空数据")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("查询表结构...")
|
||||||
|
await query("assessments", "SELECT * FROM ci_interview_assessments LIMIT 1")
|
||||||
|
await query("logs", "SELECT * FROM ci_interview_logs LIMIT 1")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
137
backend/insert_full_mock.py
Normal file
137
backend/insert_full_mock.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
插入包含完整评分字段的 Mock 数据
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
|
||||||
|
COZE_PAT_TOKEN = 'pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT'
|
||||||
|
WORKFLOW_ID = '7597376294612107318'
|
||||||
|
|
||||||
|
CANDIDATES = [
|
||||||
|
{'name': '周雪琴', 'base_score': 88},
|
||||||
|
{'name': '陈美华', 'base_score': 75},
|
||||||
|
{'name': '林婷婷', 'base_score': 92},
|
||||||
|
{'name': '黄丽萍', 'base_score': 65},
|
||||||
|
{'name': '吴晓燕', 'base_score': 82},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_sql(sql: str, table: str) -> dict:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {COZE_PAT_TOKEN}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
'workflow_id': WORKFLOW_ID,
|
||||||
|
'parameters': {
|
||||||
|
'input': json.dumps({'table': table, 'sql': sql}, ensure_ascii=False)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
r = await client.post('https://api.coze.cn/v1/workflow/run', headers=headers, json=payload)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def gen_score(base: int, variance: int = 10) -> int:
|
||||||
|
return max(50, min(100, base + random.randint(-variance, variance)))
|
||||||
|
|
||||||
|
|
||||||
|
def get_level(score: int) -> str:
|
||||||
|
if score >= 85:
|
||||||
|
return "优秀"
|
||||||
|
elif score >= 70:
|
||||||
|
return "良好"
|
||||||
|
else:
|
||||||
|
return "一般"
|
||||||
|
|
||||||
|
|
||||||
|
def escape_sql(s: str) -> str:
|
||||||
|
return s.replace("'", "''")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print('插入包含完整评分的 Mock 数据...')
|
||||||
|
print('=' * 50)
|
||||||
|
|
||||||
|
for idx, c in enumerate(CANDIDATES, 1):
|
||||||
|
session_id = f'MOCK_{uuid.uuid4().hex[:8].upper()}'
|
||||||
|
base = c['base_score']
|
||||||
|
name = c['name']
|
||||||
|
|
||||||
|
# 生成各维度分数
|
||||||
|
skill_score = gen_score(base)
|
||||||
|
concept_score = gen_score(base)
|
||||||
|
competency_score = gen_score(base)
|
||||||
|
avg_score = round((skill_score + concept_score + competency_score) / 3)
|
||||||
|
|
||||||
|
# 生成报告
|
||||||
|
skill_level = get_level(skill_score)
|
||||||
|
concept_level = get_level(concept_score)
|
||||||
|
competency_level = get_level(competency_score)
|
||||||
|
|
||||||
|
skill_report = f"销售技能评估:该候选人展现出{skill_level}的销售技巧。得分 {skill_score} 分。"
|
||||||
|
concept_report = f"销售观念评估:候选人对销售工作的认知{concept_level}。得分 {concept_score} 分。"
|
||||||
|
competency_report = f"综合素质评估:候选人的学习能力和抗压能力{competency_level}。得分 {competency_score} 分。"
|
||||||
|
|
||||||
|
recommend = "强烈推荐录用" if avg_score >= 85 else ("建议录用" if avg_score >= 70 else "建议观察")
|
||||||
|
|
||||||
|
final_report = f"""## 面试评估报告
|
||||||
|
**候选人**: {name}
|
||||||
|
**综合评分**: {avg_score}/100
|
||||||
|
|
||||||
|
### 各维度评分
|
||||||
|
- 销售技能: {skill_score}分
|
||||||
|
- 销售观念: {concept_score}分
|
||||||
|
- 综合素质: {competency_score}分
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
{recommend}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 完整的 INSERT 语句
|
||||||
|
sql = f"""INSERT INTO ci_interview_assessments (
|
||||||
|
session_id, candidate_name, current_stage,
|
||||||
|
sales_skill_score, sales_skill_report,
|
||||||
|
sales_concept_score, sales_concept_report,
|
||||||
|
competency_score, competency_report,
|
||||||
|
final_score_report
|
||||||
|
) VALUES (
|
||||||
|
'{session_id}', '{name}', 'completed',
|
||||||
|
'{skill_score}', '{escape_sql(skill_report)}',
|
||||||
|
'{concept_score}', '{escape_sql(concept_report)}',
|
||||||
|
'{competency_score}', '{escape_sql(competency_report)}',
|
||||||
|
'{escape_sql(final_report)}'
|
||||||
|
)"""
|
||||||
|
|
||||||
|
result = await execute_sql(sql, 'assessments')
|
||||||
|
status = '✅' if result.get('code') == 0 else f"❌ code={result.get('code')}"
|
||||||
|
print(f'{idx}. {name}: 技能{skill_score} 观念{concept_score} 素质{competency_score} -> {status}')
|
||||||
|
|
||||||
|
# 插入对话记录
|
||||||
|
dialogues = [
|
||||||
|
('销售技能', '请描述一次成功的销售经历', '我曾经成功说服一位犹豫的客户购买了我们的高端护肤套装,通过了解她的肤质问题,提供了针对性的方案。'),
|
||||||
|
('销售观念', '您认为什么是好的销售', '好的销售是真正帮助客户解决问题,建立长期信任关系,而不是一次性交易。'),
|
||||||
|
('综合素质', '遇到困难时您如何应对', '我会先冷静分析问题的原因,制定解决方案,并保持积极的心态去执行。'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for j, (stage, q, a) in enumerate(dialogues, 1):
|
||||||
|
log_id = f'LOG_{session_id}_{j:02d}'
|
||||||
|
log_sql = f"""INSERT INTO ci_interview_logs (
|
||||||
|
log_id, session_id, stage, round, ai_question, user_answer, log_type
|
||||||
|
) VALUES (
|
||||||
|
'{log_id}', '{session_id}', '{stage}', '{j}',
|
||||||
|
'{escape_sql(q)}', '{escape_sql(a)}', 'interview'
|
||||||
|
)"""
|
||||||
|
await execute_sql(log_sql, 'logs')
|
||||||
|
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
print('=' * 50)
|
||||||
|
print('✅ 完成!')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
261
backend/insert_mock_data.py
Normal file
261
backend/insert_mock_data.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
插入模拟面试数据到 Coze 数据库
|
||||||
|
通过工作流 C1 执行 SQL INSERT 语句
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Coze 配置
|
||||||
|
COZE_API_BASE = "https://api.coze.cn"
|
||||||
|
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
|
||||||
|
WORKFLOW_ID = "7597376294612107318" # 查询工作流 ID
|
||||||
|
|
||||||
|
# 模拟候选人数据
|
||||||
|
MOCK_CANDIDATES = [
|
||||||
|
{"name": "张小美", "score_base": 85},
|
||||||
|
{"name": "李明辉", "score_base": 78},
|
||||||
|
{"name": "王晓丽", "score_base": 92},
|
||||||
|
{"name": "陈建国", "score_base": 65},
|
||||||
|
{"name": "刘芳芳", "score_base": 72},
|
||||||
|
{"name": "赵大伟", "score_base": 58},
|
||||||
|
{"name": "孙婷婷", "score_base": 88},
|
||||||
|
{"name": "周志强", "score_base": 45},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 模拟简历内容
|
||||||
|
MOCK_RESUMES = [
|
||||||
|
"3年销售经验,曾在某知名医美机构担任销售顾问,业绩连续12个月达成率超过120%。熟悉轻医美产品线,擅长客户关系维护。",
|
||||||
|
"5年美容行业从业经验,持有高级美容师证书。性格开朗,沟通能力强,善于发现客户需求并提供专业建议。",
|
||||||
|
"应届毕业生,市场营销专业,在校期间有丰富的社团活动经验。对医美行业充满热情,学习能力强。",
|
||||||
|
"2年电商销售经验,熟悉线上运营和客户服务。希望转型到线下医美销售领域,有较强的目标感和执行力。",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 模拟评估报告模板
|
||||||
|
SKILL_REPORTS = [
|
||||||
|
"候选人展现出扎实的销售基础,在产品介绍和需求挖掘方面表现突出。能够灵活运用 FAB 法则,善于建立客户信任。建议加强异议处理技巧的训练。得分:{score}分",
|
||||||
|
"销售技能较为全面,尤其在客户沟通和关系建立方面有独到之处。能够准确把握客户心理,提供针对性的解决方案。需要在成交技巧上进一步打磨。得分:{score}分",
|
||||||
|
"基础销售技能掌握良好,但缺乏实战经验。理论知识扎实,需要更多的实践机会来提升。建议安排老带新的培训模式。得分:{score}分",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONCEPT_REPORTS = [
|
||||||
|
"对销售工作有正确的认知,理解以客户为中心的服务理念。能够平衡业绩压力和客户满意度,具有长期发展的潜力。得分:{score}分",
|
||||||
|
"销售观念较为成熟,认同医美行业的价值观。注重专业性和诚信度,能够为客户提供真诚的建议。得分:{score}分",
|
||||||
|
"对销售工作的理解还停留在表面,需要加强对行业和产品的深入学习。建议多参与案例分析和行业培训。得分:{score}分",
|
||||||
|
]
|
||||||
|
|
||||||
|
COMPETENCY_REPORTS = [
|
||||||
|
"综合素质优秀,具备良好的学习能力和抗压能力。工作态度积极,团队协作意识强。是值得培养的优秀人才。得分:{score}分",
|
||||||
|
"整体素质良好,有较强的责任心和执行力。沟通表达清晰,形象气质佳。建议加强时间管理能力。得分:{score}分",
|
||||||
|
"基本素质合格,但在主动性和自驱力方面有提升空间。需要更多的激励和引导来激发潜力。得分:{score}分",
|
||||||
|
]
|
||||||
|
|
||||||
|
MOTIVATION_SUMMARIES = [
|
||||||
|
"候选人对轻医美行业表现出浓厚的兴趣,希望在这个领域长期发展。主要动机包括:1)看好行业发展前景;2)认同公司品牌价值;3)期待获得专业成长机会。",
|
||||||
|
"求职动机明确,主要是希望获得更好的收入和职业发展平台。对销售工作有热情,愿意接受挑战和压力。",
|
||||||
|
"动机较为单一,主要关注薪资待遇。建议在后续面试中进一步了解其对行业的认知和长期规划。",
|
||||||
|
]
|
||||||
|
|
||||||
|
RISK_WARNINGS = [
|
||||||
|
"", # 无风险
|
||||||
|
"", # 无风险
|
||||||
|
"候选人在上一份工作中存在频繁跳槽的情况,需要关注稳定性问题。建议在背调中重点核实离职原因。",
|
||||||
|
"面试过程中发现候选人对公司产品了解不够深入,可能存在准备不充分的情况。建议安排二次面试进一步评估。",
|
||||||
|
"候选人期望薪资与市场水平存在一定差距,需要在 Offer 阶段进行合理沟通。",
|
||||||
|
]
|
||||||
|
|
||||||
|
GROWTH_PLANS = [
|
||||||
|
"建议培养方向:1)前3个月重点学习产品知识和销售流程;2)3-6个月参与实战,由资深顾问带教;3)6个月后独立负责客户。预计1年内可成长为骨干销售。",
|
||||||
|
"该候选人具备快速成长的潜力,建议:1)入职即安排系统培训;2)重点培养其客户开发能力;3)可考虑作为储备管理人才培养。",
|
||||||
|
"成长空间有限,建议先观察3个月试用期表现再做进一步评估。",
|
||||||
|
]
|
||||||
|
|
||||||
|
REF_CHECK_LISTS = [
|
||||||
|
"建议背调问题:\n1. 请确认候选人在贵公司的任职时间和职位\n2. 候选人的主要工作职责是什么?\n3. 您如何评价候选人的销售能力?\n4. 候选人的离职原因是什么?\n5. 如果有机会,您是否愿意再次与其共事?",
|
||||||
|
"重点背调事项:\n1. 核实业绩数据的真实性\n2. 了解团队协作情况\n3. 确认是否有劳动纠纷\n4. 验证学历和证书的真实性",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 模拟对话记录
|
||||||
|
MOCK_DIALOGUES = [
|
||||||
|
{
|
||||||
|
"stage": "开场",
|
||||||
|
"ai_question": "您好!欢迎参加我们的 AI 面试。我是您的 AI 面试官,接下来我们将进行一次关于销售岗位的面试。首先,请您简单介绍一下自己。",
|
||||||
|
"user_answer": "您好,我叫{name},今年28岁。我有3年的销售工作经验,主要在美容行业从事客户服务和销售工作。我性格开朗,善于与人沟通,对轻医美行业非常感兴趣。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "销售技能",
|
||||||
|
"ai_question": "很好,感谢您的自我介绍。接下来我想了解一下您的销售经验。请您描述一次成功的销售案例,包括您是如何发现客户需求、如何推荐产品、以及最终如何促成交易的。",
|
||||||
|
"user_answer": "好的。去年我接待了一位40多岁的女士,她最初只是来咨询皮肤保养。通过聊天我发现她其实对法令纹比较在意。我没有直接推销,而是先帮她做了皮肤检测,用数据说明问题。然后根据她的预算和需求,推荐了适合的玻尿酸填充方案。整个过程我注重建立信任,最终她不仅做了法令纹填充,还办了年卡。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "销售技能",
|
||||||
|
"ai_question": "非常好的案例分享。那么当客户对价格有异议时,您通常如何处理?",
|
||||||
|
"user_answer": "价格异议是很常见的。我通常会先认同客户的感受,然后分析价值而不是价格。比如我会说'您的担心我理解,选择医美确实需要慎重考虑。不过您看,我们使用的是进口正品,由资深医生操作,术后还有专业跟踪服务。很多客户反馈效果能保持1-2年,算下来其实性价比很高。'另外我也会提供分期付款等灵活方案。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "销售观",
|
||||||
|
"ai_question": "您如何看待销售工作?您认为一个优秀的销售顾问最重要的品质是什么?",
|
||||||
|
"user_answer": "我认为销售的本质是帮助客户解决问题,而不是单纯的推销产品。一个优秀的销售顾问首先要专业,要真正了解产品和客户需求;其次要真诚,不能为了业绩误导客户;最后要有服务意识,把每一位客户都当作长期朋友来维护。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "素质项",
|
||||||
|
"ai_question": "假设您在工作中遇到了连续三个月业绩不达标的情况,您会如何应对?",
|
||||||
|
"user_answer": "首先我会分析原因,是市场问题、个人方法问题还是其他因素。然后我会主动向业绩好的同事请教,学习他们的成功经验。同时我会调整自己的工作方法,比如增加客户回访频率、优化话术等。最重要的是保持积极心态,相信通过努力一定能改善。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "求职动机",
|
||||||
|
"ai_question": "最后一个问题,您为什么选择我们公司?您对未来的职业发展有什么规划?",
|
||||||
|
"user_answer": "选择贵公司主要有三个原因:一是贵公司在轻医美领域有很好的口碑和品牌影响力;二是听说公司非常注重员工培训和成长;三是公司的企业文化和我的价值观很匹配。未来我希望能在1-2年内成长为资深销售顾问,3年内有机会带领小团队,为公司创造更大价值。"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_sql(sql: str, table: str) -> dict:
|
||||||
|
"""通过工作流执行 SQL"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workflow_id": WORKFLOW_ID,
|
||||||
|
"parameters": {
|
||||||
|
"input": json.dumps({"table": table, "sql": sql}, ensure_ascii=False)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{COZE_API_BASE}/v1/workflow/run",
|
||||||
|
headers=headers,
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_id(name: str, idx: int) -> str:
|
||||||
|
"""生成 session_id"""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d%H%M")
|
||||||
|
return f"SESS_{timestamp}_{name}_{idx:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_assessment_sql(session_id: str, candidate: dict, idx: int) -> str:
|
||||||
|
"""生成评估记录 INSERT SQL"""
|
||||||
|
base = candidate["score_base"]
|
||||||
|
variation = random.randint(-5, 10)
|
||||||
|
|
||||||
|
skill_score = min(100, max(0, base + random.randint(-8, 8)))
|
||||||
|
concept_score = min(100, max(0, base + random.randint(-5, 10)))
|
||||||
|
competency_score = min(100, max(0, base + random.randint(-10, 5)))
|
||||||
|
|
||||||
|
skill_report = random.choice(SKILL_REPORTS).format(score=skill_score)
|
||||||
|
concept_report = random.choice(CONCEPT_REPORTS).format(score=concept_score)
|
||||||
|
competency_report = random.choice(COMPETENCY_REPORTS).format(score=competency_score)
|
||||||
|
|
||||||
|
motivation = random.choice(MOTIVATION_SUMMARIES)
|
||||||
|
risk = random.choice(RISK_WARNINGS)
|
||||||
|
growth = random.choice(GROWTH_PLANS)
|
||||||
|
ref_check = random.choice(REF_CHECK_LISTS)
|
||||||
|
resume = random.choice(MOCK_RESUMES)
|
||||||
|
|
||||||
|
# 转义单引号
|
||||||
|
def escape(s):
|
||||||
|
return s.replace("'", "''") if s else ""
|
||||||
|
|
||||||
|
sql = f"""INSERT INTO ci_interview_assessments (
|
||||||
|
session_id, candidate_name, current_stage,
|
||||||
|
sales_skill_score, sales_skill_report,
|
||||||
|
sales_concept_score, sales_concept_report,
|
||||||
|
competency_score, competency_report,
|
||||||
|
motivation_summary, risk_warning, growth_plan, ref_check_list, resume_text
|
||||||
|
) VALUES (
|
||||||
|
'{session_id}', '{candidate["name"]}', '10',
|
||||||
|
'{skill_score}', '{escape(skill_report)}',
|
||||||
|
'{concept_score}', '{escape(concept_report)}',
|
||||||
|
'{competency_score}', '{escape(competency_report)}',
|
||||||
|
'{escape(motivation)}', '{escape(risk)}', '{escape(growth)}', '{escape(ref_check)}', '{escape(resume)}'
|
||||||
|
)"""
|
||||||
|
|
||||||
|
return sql
|
||||||
|
|
||||||
|
|
||||||
|
def generate_log_sql(session_id: str, candidate_name: str, round_num: int, dialogue: dict, idx: int) -> str:
|
||||||
|
"""生成对话记录 INSERT SQL"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
def escape(s):
|
||||||
|
return s.replace("'", "''").replace("{name}", candidate_name) if s else ""
|
||||||
|
|
||||||
|
# 生成唯一的 log_id
|
||||||
|
log_id = f"LOG_{idx:02d}_{round_num:02d}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
sql = f"""INSERT INTO ci_interview_logs (
|
||||||
|
log_id, session_id, stage, round, ai_question, user_answer, log_type
|
||||||
|
) VALUES (
|
||||||
|
'{log_id}', '{session_id}', '{dialogue["stage"]}', '{round_num}',
|
||||||
|
'{escape(dialogue["ai_question"])}', '{escape(dialogue["user_answer"])}', 'interview'
|
||||||
|
)"""
|
||||||
|
|
||||||
|
return sql
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("开始插入模拟面试数据...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for idx, candidate in enumerate(MOCK_CANDIDATES):
|
||||||
|
session_id = generate_session_id(candidate["name"], idx)
|
||||||
|
print(f"\n[{idx + 1}/{len(MOCK_CANDIDATES)}] 处理候选人: {candidate['name']}")
|
||||||
|
print(f" Session ID: {session_id}")
|
||||||
|
|
||||||
|
# 1. 插入评估记录
|
||||||
|
try:
|
||||||
|
assessment_sql = generate_assessment_sql(session_id, candidate, idx)
|
||||||
|
print(f" 插入评估记录...")
|
||||||
|
result = await execute_sql(assessment_sql, "assessments")
|
||||||
|
|
||||||
|
if result.get("code") == 0:
|
||||||
|
print(f" ✓ 评估记录插入成功")
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
print(f" ✗ 评估记录插入失败: {result}")
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ 评估记录插入异常: {e}")
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
# 2. 插入对话记录
|
||||||
|
for round_num, dialogue in enumerate(MOCK_DIALOGUES, 1):
|
||||||
|
try:
|
||||||
|
log_sql = generate_log_sql(session_id, candidate["name"], round_num, dialogue, idx)
|
||||||
|
print(f" 插入对话记录 (第{round_num}轮)...")
|
||||||
|
result = await execute_sql(log_sql, "logs")
|
||||||
|
|
||||||
|
if result.get("code") == 0:
|
||||||
|
print(f" ✓ 对话记录 {round_num} 插入成功")
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
print(f" ✗ 对话记录 {round_num} 插入失败: {result}")
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ 对话记录 {round_num} 插入异常: {e}")
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
# 等待一下避免请求过快
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"数据插入完成!")
|
||||||
|
print(f"成功: {success_count} 条")
|
||||||
|
print(f"失败: {error_count} 条")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
123
backend/insert_mock_full.py
Normal file
123
backend/insert_mock_full.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
插入完整模拟面试数据(8 条候选人 + 对话记录)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
COZE_API_BASE = "https://api.coze.cn"
|
||||||
|
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
|
||||||
|
WORKFLOW_ID = "7597376294612107318"
|
||||||
|
|
||||||
|
# 候选人数据
|
||||||
|
CANDIDATES = [
|
||||||
|
{"name": "张小美", "skill": 85, "concept": 82, "comp": 88, "status": "completed", "risk": ""},
|
||||||
|
{"name": "李明辉", "skill": 72, "concept": 70, "comp": 75, "status": "completed", "risk": ""},
|
||||||
|
{"name": "王晓丽", "skill": 92, "concept": 90, "comp": 95, "status": "completed", "risk": ""},
|
||||||
|
{"name": "陈建国", "skill": 65, "concept": 60, "comp": 68, "status": "completed", "risk": "面试中表现紧张,需关注"},
|
||||||
|
{"name": "刘芳芳", "skill": 78, "concept": 75, "comp": 80, "status": "completed", "risk": ""},
|
||||||
|
{"name": "赵大伟", "skill": 55, "concept": 52, "comp": 58, "status": "completed", "risk": "销售经验不足,需重点培训"},
|
||||||
|
{"name": "孙婷婷", "skill": 88, "concept": 85, "comp": 90, "status": "in_progress", "risk": ""},
|
||||||
|
{"name": "周志强", "skill": 45, "concept": 42, "comp": 48, "status": "completed", "risk": "不建议录用,综合能力较弱"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 简历模板
|
||||||
|
RESUMES = [
|
||||||
|
"3年销售经验,曾在某知名医美机构担任销售顾问,业绩连续12个月达成率超过120%。",
|
||||||
|
"5年美容行业从业经验,持有高级美容师证书。性格开朗,沟通能力强。",
|
||||||
|
"应届毕业生,市场营销专业,在校期间有丰富的社团活动经验。对医美行业充满热情。",
|
||||||
|
"2年电商销售经验,熟悉线上运营和客户服务。希望转型到线下医美销售领域。",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 对话模板
|
||||||
|
DIALOGUES = [
|
||||||
|
("开场", "你好,请先做个自我介绍", "面试官您好,我是{name},很高兴参加这次面试。我之前有销售相关经验,对轻医美行业很感兴趣。"),
|
||||||
|
("技能", "请介绍你常用的销售技巧", "我主要采用顾问式销售方法,先通过沟通了解客户的真实需求,然后针对性地推荐适合的产品或服务。"),
|
||||||
|
("技能", "遇到客户异议时如何处理", "首先我会认真倾听客户的顾虑,表示理解。然后用专业知识解答疑问,必要时提供案例或数据支持。"),
|
||||||
|
("观念", "你如何看待销售这份工作", "我认为销售不仅是卖产品,更是帮助客户解决问题。好的销售是客户的顾问和朋友。"),
|
||||||
|
("素质", "你的职业规划是什么", "短期希望成为一名优秀的销售顾问,中期目标是带领团队,长期希望在医美行业有深入发展。"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def execute_sql(sql: str, table: str) -> dict:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
input_json = json.dumps({"table": table, "sql": sql}, ensure_ascii=False)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{COZE_API_BASE}/v1/workflow/run",
|
||||||
|
headers=headers,
|
||||||
|
json={"workflow_id": WORKFLOW_ID, "parameters": {"input": input_json}}
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def escape(s):
|
||||||
|
"""转义 SQL 特殊字符"""
|
||||||
|
return s.replace("'", "''") if s else ""
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("插入完整模拟数据 (8 条候选人)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for idx, c in enumerate(CANDIDATES):
|
||||||
|
session_id = f"MOCK_{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
resume = RESUMES[idx % len(RESUMES)]
|
||||||
|
|
||||||
|
# 生成报告
|
||||||
|
skill_report = f"销售技能评估:候选人在产品介绍和需求挖掘方面{'表现出色' if c['skill'] >= 80 else '有待提升'}。得分:{c['skill']}分"
|
||||||
|
concept_report = f"销售观念评估:对销售工作{'有正确认知' if c['concept'] >= 70 else '认识不够深入'}。得分:{c['concept']}分"
|
||||||
|
comp_report = f"综合素质评估:学习能力{'强' if c['comp'] >= 80 else '一般'},抗压能力{'好' if c['comp'] >= 75 else '需加强'}。得分:{c['comp']}分"
|
||||||
|
|
||||||
|
# 1. 插入 Assessment
|
||||||
|
sql1 = f"""INSERT INTO ci_interview_assessments (
|
||||||
|
session_id, candidate_name, resume_text, current_stage,
|
||||||
|
sales_skill_score, sales_concept_score, competency_score,
|
||||||
|
sales_skill_report, sales_concept_report, competency_report,
|
||||||
|
motivation_summary, risk_warning, growth_plan, ref_check_list
|
||||||
|
) VALUES (
|
||||||
|
'{session_id}', '{c["name"]}', '{escape(resume)}', '{c["status"]}',
|
||||||
|
'{c["skill"]}', '{c["concept"]}', '{c["comp"]}',
|
||||||
|
'{escape(skill_report)}', '{escape(concept_report)}', '{escape(comp_report)}',
|
||||||
|
'候选人对轻医美行业表现出兴趣,希望长期发展。',
|
||||||
|
'{escape(c["risk"])}',
|
||||||
|
'建议入职后进行系统培训,由资深顾问带教。',
|
||||||
|
'建议背调:确认任职时间、业绩数据、离职原因。'
|
||||||
|
)"""
|
||||||
|
|
||||||
|
print(f"\n[{idx+1}/8] {c['name']} (session: {session_id})")
|
||||||
|
result = await execute_sql(sql1, "assessments")
|
||||||
|
|
||||||
|
if result.get('code') != 0:
|
||||||
|
print(f" ❌ Assessment 失败: {result.get('msg')}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" ✅ Assessment: {c['skill']}/{c['concept']}/{c['comp']}")
|
||||||
|
|
||||||
|
# 2. 插入对话记录
|
||||||
|
for d_idx, (stage, q, a) in enumerate(DIALOGUES, 1):
|
||||||
|
log_id = f"LOG_{session_id}_{d_idx:02d}"
|
||||||
|
answer = a.replace("{name}", c["name"])
|
||||||
|
|
||||||
|
sql2 = f"""INSERT INTO ci_interview_logs (
|
||||||
|
log_id, session_id, stage, round, ai_question, user_answer, log_type
|
||||||
|
) VALUES (
|
||||||
|
'{log_id}', '{session_id}', '{stage}', '{d_idx}',
|
||||||
|
'{escape(q)}', '{escape(answer)}', 'interview'
|
||||||
|
)"""
|
||||||
|
|
||||||
|
result = await execute_sql(sql2, "logs")
|
||||||
|
if result.get('code') != 0:
|
||||||
|
print(f" ⚠️ 对话{d_idx}: {result.get('msg')[:30]}")
|
||||||
|
|
||||||
|
print(f" ✅ 对话: {len(DIALOGUES)} 条")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ 完成!共插入 8 条候选人 + 40 条对话记录")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
100
backend/insert_mock_simple.py
Normal file
100
backend/insert_mock_simple.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
简化版 Mock 数据插入脚本 - 只插入2条记录
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
|
||||||
|
COZE_API_BASE = "https://api.coze.cn"
|
||||||
|
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
|
||||||
|
WORKFLOW_ID = "7597376294612107318"
|
||||||
|
|
||||||
|
# 模拟候选人
|
||||||
|
CANDIDATES = [
|
||||||
|
{"name": "张小美", "score": 85, "status": "completed", "risk": "low"},
|
||||||
|
{"name": "李明辉", "score": 72, "status": "completed", "risk": "medium"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 简化对话
|
||||||
|
DIALOGUES = [
|
||||||
|
{"stage": "专业技能", "q": "请介绍您的护肤专业知识", "a": "我在医美行业有3年经验..."},
|
||||||
|
{"stage": "沟通能力", "q": "遇到客户投诉如何处理", "a": "首先我会认真倾听客户的问题..."},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_sql(sql: str, table: str) -> dict:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"workflow_id": WORKFLOW_ID,
|
||||||
|
"parameters": {"input": json.dumps({"table": table, "sql": sql}, ensure_ascii=False)}
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
r = await client.post(f"{COZE_API_BASE}/v1/workflow/run", headers=headers, json=payload)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 50)
|
||||||
|
print("插入 Mock 数据")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
for idx, c in enumerate(CANDIDATES, 1):
|
||||||
|
session_id = f"MOCK_{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
|
||||||
|
# 生成评估报告
|
||||||
|
report = f"""## 面试评估报告
|
||||||
|
|
||||||
|
**候选人**: {c['name']}
|
||||||
|
**综合评分**: {c['score']}/100
|
||||||
|
|
||||||
|
### 各维度评分
|
||||||
|
- 专业技能: {random.randint(70, 95)}分
|
||||||
|
- 沟通能力: {random.randint(70, 95)}分
|
||||||
|
- 服务意识: {random.randint(70, 95)}分
|
||||||
|
- 学习能力: {random.randint(70, 95)}分
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
风险等级: {c['risk']}
|
||||||
|
|
||||||
|
### 总结
|
||||||
|
该候选人整体表现{"良好" if c['score'] >= 80 else "一般"},建议{"录用" if c['score'] >= 75 else "观察"}。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 1. 插入评估记录
|
||||||
|
sql1 = f"""INSERT INTO ci_interview_assessments (
|
||||||
|
session_id, candidate_name, assessment_report, current_stage
|
||||||
|
) VALUES (
|
||||||
|
'{session_id}', '{c["name"]}', '{report.replace("'", "''")}', '{c["status"]}'
|
||||||
|
)"""
|
||||||
|
|
||||||
|
print(f"\n📝 插入候选人 {idx}: {c['name']}")
|
||||||
|
result = await execute_sql(sql1, "assessments")
|
||||||
|
print(f" 评估记录: code={result.get('code')}")
|
||||||
|
|
||||||
|
# 2. 插入对话记录
|
||||||
|
for j, d in enumerate(DIALOGUES, 1):
|
||||||
|
log_id = f"LOG_{session_id}_{j:02d}"
|
||||||
|
sql2 = f"""INSERT INTO ci_interview_logs (
|
||||||
|
log_id, session_id, stage, round, ai_question, user_answer, log_type
|
||||||
|
) VALUES (
|
||||||
|
'{log_id}', '{session_id}', '{d["stage"]}', '{j}',
|
||||||
|
'{d["q"].replace("'", "''")}', '{d["a"].replace("'", "''")}', 'interview'
|
||||||
|
)"""
|
||||||
|
|
||||||
|
result = await execute_sql(sql2, "logs")
|
||||||
|
print(f" 对话{j}: code={result.get('code')}")
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("✅ 完成!")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
73
backend/main.py
Normal file
73
backend/main.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
AI Interview Backend - FastAPI 应用入口
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.routers import candidate, room, chat, init, files, admin
|
||||||
|
|
||||||
|
# 创建 FastAPI 应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="AI Interview API",
|
||||||
|
description="AI 语音面试系统后端 API",
|
||||||
|
version="0.1.0",
|
||||||
|
docs_url="/docs" if settings.DEBUG else None,
|
||||||
|
redoc_url="/redoc" if settings.DEBUG else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 配置 CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册路由(用户端)
|
||||||
|
app.include_router(candidate.router, prefix="/api", tags=["候选人"])
|
||||||
|
app.include_router(room.router, prefix="/api", tags=["房间"])
|
||||||
|
app.include_router(chat.router, prefix="/api", tags=["对话"])
|
||||||
|
app.include_router(init.router, prefix="/api", tags=["初始化"])
|
||||||
|
app.include_router(files.router, prefix="/api", tags=["文件"])
|
||||||
|
|
||||||
|
# 管理后台路由
|
||||||
|
app.include_router(admin.router, tags=["管理后台"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return {"status": "ok", "version": "0.1.0"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""应用启动事件"""
|
||||||
|
logger.info("AI Interview Backend starting...")
|
||||||
|
logger.info(f"Debug mode: {settings.DEBUG}")
|
||||||
|
logger.info(f"Coze Bot ID: {settings.COZE_BOT_ID}")
|
||||||
|
logger.info(f"Tunnel URL: {settings.TUNNEL_URL or settings.NGROK_URL}")
|
||||||
|
|
||||||
|
# 创建上传目录
|
||||||
|
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||||
|
logger.info(f"Upload directory: {settings.UPLOAD_DIR}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
"""应用关闭事件"""
|
||||||
|
logger.info("AI Interview Backend shutting down...")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=settings.API_PORT,
|
||||||
|
reload=settings.DEBUG,
|
||||||
|
)
|
||||||
26
backend/requirements.txt
Normal file
26
backend/requirements.txt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# FastAPI 框架
|
||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn[standard]>=0.27.0
|
||||||
|
|
||||||
|
# HTTP 客户端
|
||||||
|
httpx>=0.26.0
|
||||||
|
|
||||||
|
# 文件上传
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
|
||||||
|
# 数据验证
|
||||||
|
pydantic>=2.5.0
|
||||||
|
pydantic-settings>=2.1.0
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
starlette>=0.35.0
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
loguru>=0.7.2
|
||||||
|
|
||||||
|
# PDF 生成(可选)
|
||||||
|
# weasyprint>=60.0
|
||||||
|
# reportlab>=4.0.0
|
||||||
167
backend/test_coze.py
Normal file
167
backend/test_coze.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
Coze API 配置测试脚本
|
||||||
|
运行: python test_coze.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
COZE_API_BASE = "https://api.coze.cn"
|
||||||
|
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
|
||||||
|
COZE_BOT_ID = "7595113005181386792"
|
||||||
|
COZE_DATABASE_ID = "7595077053909712922"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bot_info():
|
||||||
|
"""测试 Bot 信息"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("1. 测试 Bot 信息")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
url = f"{COZE_API_BASE}/v1/bot/get_online_info"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
params={"bot_id": COZE_BOT_ID},
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
print(f"Response: {data}")
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
bot_info = data.get("data", {})
|
||||||
|
print(f"\n✅ Bot 名称: {bot_info.get('name', 'N/A')}")
|
||||||
|
print(f"✅ Bot ID: {COZE_BOT_ID}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"\n❌ 错误: {data.get('msg', 'Unknown error')}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 请求失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_database():
|
||||||
|
"""测试数据库查询"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("2. 测试数据库连接和字段结构")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
url = f"{COZE_API_BASE}/v1/database/query"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"database_id": COZE_DATABASE_ID,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(url, json=payload, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
db_data = data.get("data", {})
|
||||||
|
records = db_data.get("records", [])
|
||||||
|
total = db_data.get("total", 0)
|
||||||
|
|
||||||
|
print(f"\n✅ 数据库连接成功!")
|
||||||
|
print(f"✅ 数据库 ID: {COZE_DATABASE_ID}")
|
||||||
|
print(f"✅ 总记录数: {total}")
|
||||||
|
|
||||||
|
if records:
|
||||||
|
print(f"\n📋 数据库字段列表:")
|
||||||
|
first_record = records[0]
|
||||||
|
for key in first_record.keys():
|
||||||
|
value = first_record[key]
|
||||||
|
value_preview = str(value)[:50] + "..." if len(str(value)) > 50 else value
|
||||||
|
print(f" - {key}: {value_preview}")
|
||||||
|
else:
|
||||||
|
print("\n⚠️ 数据库为空,无法显示字段结构")
|
||||||
|
print(" 这是正常的,首次面试后会有数据")
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"\n❌ 错误: {data.get('msg', 'Unknown error')}")
|
||||||
|
print(f" 完整响应: {data}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 请求失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_audio_room():
|
||||||
|
"""测试语音房间创建(不实际创建,只检查 API 可用性)"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("3. 测试语音房间 API(模拟请求)")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 我们只测试 API 是否可访问,不实际创建房间
|
||||||
|
url = f"{COZE_API_BASE}/v1/audio/rooms"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用测试参数
|
||||||
|
payload = {
|
||||||
|
"bot_id": COZE_BOT_ID,
|
||||||
|
"user_id": "test_user_001",
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"API 端点: {url}")
|
||||||
|
print(f"请求参数: {payload}")
|
||||||
|
print("\n⚠️ 跳过实际创建房间测试(避免产生费用)")
|
||||||
|
print("✅ API 配置看起来正确")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("\n🔍 Coze API 配置测试")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"API Base: {COZE_API_BASE}")
|
||||||
|
print(f"Bot ID: {COZE_BOT_ID}")
|
||||||
|
print(f"Database ID: {COZE_DATABASE_ID}")
|
||||||
|
print(f"Token: {COZE_PAT_TOKEN[:20]}...")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 测试 Bot
|
||||||
|
results.append(await test_bot_info())
|
||||||
|
|
||||||
|
# 测试数据库
|
||||||
|
results.append(await test_database())
|
||||||
|
|
||||||
|
# 测试语音房间
|
||||||
|
results.append(await test_audio_room())
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("📊 测试结果汇总")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
tests = ["Bot 信息", "数据库连接", "语音房间 API"]
|
||||||
|
for i, (test_name, result) in enumerate(zip(tests, results)):
|
||||||
|
status = "✅ 通过" if result else "❌ 失败"
|
||||||
|
print(f"{i + 1}. {test_name}: {status}")
|
||||||
|
|
||||||
|
if all(results):
|
||||||
|
print("\n🎉 所有测试通过!配置正确!")
|
||||||
|
else:
|
||||||
|
print("\n⚠️ 部分测试失败,请检查配置")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
78
backend/test_insert_log.py
Normal file
78
backend/test_insert_log.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
测试插入单条对话记录
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
|
||||||
|
COZE_API_BASE = "https://api.coze.cn"
|
||||||
|
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
|
||||||
|
WORKFLOW_ID = "7597376294612107318"
|
||||||
|
|
||||||
|
async def execute_sql(sql: str, table: str) -> dict:
|
||||||
|
"""通过工作流执行 SQL"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
input_json = json.dumps({"table": table, "sql": sql}, ensure_ascii=False)
|
||||||
|
print(f"\n📤 发送请求:")
|
||||||
|
print(f" table: {table}")
|
||||||
|
print(f" sql: {sql[:100]}...")
|
||||||
|
print(f" input: {input_json[:200]}...")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workflow_id": WORKFLOW_ID,
|
||||||
|
"parameters": {
|
||||||
|
"input": input_json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{COZE_API_BASE}/v1/workflow/run",
|
||||||
|
headers=headers,
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
result = response.json()
|
||||||
|
print(f"\n📥 响应:")
|
||||||
|
print(f" code: {result.get('code')}")
|
||||||
|
print(f" msg: {result.get('msg')}")
|
||||||
|
if result.get('data'):
|
||||||
|
print(f" data: {result.get('data')[:200]}...")
|
||||||
|
if result.get('debug_url'):
|
||||||
|
print(f" debug: {result.get('debug_url')}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("测试 INSERT 到 ci_interview_logs")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 测试 1: 简单的 INSERT
|
||||||
|
sql1 = """INSERT INTO ci_interview_logs (session_id, stage, round, ai_question, user_answer, log_type) VALUES ('TEST_001', '测试', '1', '测试问题', '测试回答', 'test')"""
|
||||||
|
|
||||||
|
print("\n\n🧪 测试 1: 简单 INSERT")
|
||||||
|
result = await execute_sql(sql1, "logs")
|
||||||
|
|
||||||
|
# 测试 2: 查询刚插入的数据
|
||||||
|
sql2 = """SELECT * FROM ci_interview_logs WHERE session_id = 'TEST_001' LIMIT 5"""
|
||||||
|
|
||||||
|
print("\n\n🧪 测试 2: 查询刚插入的数据")
|
||||||
|
result = await execute_sql(sql2, "logs")
|
||||||
|
|
||||||
|
# 测试 3: 删除测试数据
|
||||||
|
sql3 = """DELETE FROM ci_interview_logs WHERE session_id = 'TEST_001'"""
|
||||||
|
|
||||||
|
print("\n\n🧪 测试 3: 删除测试数据")
|
||||||
|
result = await execute_sql(sql3, "logs")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("测试完成")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
38
backend/test_single_insert.py
Normal file
38
backend/test_single_insert.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""测试单条 INSERT"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
COZE_API_BASE = "https://api.coze.cn"
|
||||||
|
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
|
||||||
|
WORKFLOW_ID = "7597376294612107318"
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
log_id = f"LOG_TEST_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
sql = f"""INSERT INTO ci_interview_logs (log_id, session_id, stage, round, ai_question, user_answer, log_type) VALUES ('{log_id}', 'TEST_001', '测试', '1', '测试问题', '测试回答', 'test')"""
|
||||||
|
|
||||||
|
input_json = json.dumps({"table": "logs", "sql": sql}, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f"log_id: {log_id}")
|
||||||
|
print(f"sql: {sql[:80]}...")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{COZE_API_BASE}/v1/workflow/run",
|
||||||
|
headers=headers,
|
||||||
|
json={"workflow_id": WORKFLOW_ID, "parameters": {"input": input_json}}
|
||||||
|
)
|
||||||
|
result = response.json()
|
||||||
|
print(f"code: {result.get('code')}")
|
||||||
|
print(f"msg: {result.get('msg')}")
|
||||||
|
print(f"data: {result.get('data', '')[:200]}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
89
backend/test_workflow_c.py
Normal file
89
backend/test_workflow_c.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
测试工作流 C - 通用 SQL 查询(JSON 格式输入)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
PAT_TOKEN = os.getenv("COZE_PAT_TOKEN")
|
||||||
|
WORKFLOW_ID = "7597376294612107318"
|
||||||
|
|
||||||
|
async def test_query(table: str, sql: str):
|
||||||
|
url = "https://api.coze.cn/v1/workflow/run"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {PAT_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# JSON 格式输入
|
||||||
|
input_data = json.dumps({
|
||||||
|
"table": table,
|
||||||
|
"sql": sql
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workflow_id": WORKFLOW_ID,
|
||||||
|
"parameters": {
|
||||||
|
"input": input_data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Table: {table}")
|
||||||
|
print(f"SQL: {sql[:80]}...")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(url, json=payload, headers=headers)
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
print(f"✅ 成功!")
|
||||||
|
result_str = data.get("data", "")
|
||||||
|
if result_str:
|
||||||
|
try:
|
||||||
|
inner_data = json.loads(result_str)
|
||||||
|
if isinstance(inner_data, list):
|
||||||
|
print(f"返回 {len(inner_data)} 条记录")
|
||||||
|
if inner_data:
|
||||||
|
print(json.dumps(inner_data[0], indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(json.dumps(inner_data, indent=2, ensure_ascii=False))
|
||||||
|
except:
|
||||||
|
print(f"Raw: {result_str[:300]}")
|
||||||
|
else:
|
||||||
|
print(f"❌ 失败: {data.get('msg')}")
|
||||||
|
if data.get("debug_url"):
|
||||||
|
print(f"Debug: {data.get('debug_url')}")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("测试 1: 查询面试评估列表 (assessments)")
|
||||||
|
print("=" * 60)
|
||||||
|
await test_query(
|
||||||
|
"assessments",
|
||||||
|
"SELECT session_id, candidate_name, bstudio_create_time FROM ci_interview_assessments ORDER BY bstudio_create_time DESC LIMIT 5"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("测试 2: 查询对话日志 (logs)")
|
||||||
|
print("=" * 60)
|
||||||
|
await test_query(
|
||||||
|
"logs",
|
||||||
|
"SELECT log_id, session_id, stage, ai_question, user_answer FROM ci_interview_logs LIMIT 3"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("测试 3: 查询业务配置 (config)")
|
||||||
|
print("=" * 60)
|
||||||
|
await test_query(
|
||||||
|
"config",
|
||||||
|
"SELECT config_id, config_type, item_name FROM ci_business_config LIMIT 3"
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
BIN
backend/uploads/resume_06efd8fbdf4e.pdf
Normal file
BIN
backend/uploads/resume_06efd8fbdf4e.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_0c832769e6e1.pdf
Normal file
BIN
backend/uploads/resume_0c832769e6e1.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_12d917aff494.pdf
Normal file
BIN
backend/uploads/resume_12d917aff494.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_314534591e3a.pdf
Normal file
BIN
backend/uploads/resume_314534591e3a.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_54c858df7b5c.pdf
Normal file
BIN
backend/uploads/resume_54c858df7b5c.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_5649e33085e7.pdf
Normal file
BIN
backend/uploads/resume_5649e33085e7.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_822a417ddd93.pdf
Normal file
BIN
backend/uploads/resume_822a417ddd93.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_9776de07e4a8.pdf
Normal file
BIN
backend/uploads/resume_9776de07e4a8.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_9c8d14dffe57.pdf
Normal file
BIN
backend/uploads/resume_9c8d14dffe57.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_a1046f432e25.pdf
Normal file
BIN
backend/uploads/resume_a1046f432e25.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_c413896bb575.pdf
Normal file
BIN
backend/uploads/resume_c413896bb575.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_c66c17984409.pdf
Normal file
BIN
backend/uploads/resume_c66c17984409.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_d5099390d096.pdf
Normal file
BIN
backend/uploads/resume_d5099390d096.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_ea022e674281.pdf
Normal file
BIN
backend/uploads/resume_ea022e674281.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_eeec534465a4.pdf
Normal file
BIN
backend/uploads/resume_eeec534465a4.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_fe9abe3de07a.pdf
Normal file
BIN
backend/uploads/resume_fe9abe3de07a.pdf
Normal file
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
type: Workflow
|
||||||
|
version: 1.0.0
|
||||||
|
main:
|
||||||
|
id: 7595077233002840079
|
||||||
|
name: Consultant_interviewer
|
||||||
|
desc: |-
|
||||||
|
“咨询师面试官” 是专注轻医美咨询师初试的智能人力资源 AI,以精准评估、高效筛选为核心,助力企业锁定适配人才。
|
||||||
|
它围绕销售观、销售技巧、知识储备与职业素质四大维度,运用 STAR 面试法深入挖掘候选人行为细节,配合性格剖面报告与行为提问话术库,剖析其性格特质与领导力潜力,同步生成任用风险提示与决策矩阵图,规避用人风险。针对轻医美行业特性,重点评估 “情绪稳定性”,结合德锐 9 大致命点,从技能、知识、价值观等方面综合评分。
|
||||||
|
面试结束后,Agent 输出精准面试评分、背调问题清单,还定制未来 5 年培养规划,实现人才的可持续发展。后台的人才画像卡支持个性化生成,适配不同企业对素质项的筛选需求。凭借专业、智能的特性,“咨询师面试官” Agent 成为企业构建轻医美人才梯队的有力工具。
|
||||||
|
icon: plugin_icon/workflow.png
|
||||||
|
version: ""
|
||||||
|
flowMode: 3
|
||||||
|
commitId: ""
|
||||||
|
sub: []
|
||||||
File diff suppressed because one or more lines are too long
72
coze-workflows/README.md
Normal file
72
coze-workflows/README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Coze 工作流
|
||||||
|
|
||||||
|
本目录存放 Coze 平台导出的工作流文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作流列表
|
||||||
|
|
||||||
|
| 名称 | ID | 描述 | 状态 |
|
||||||
|
|------|-----|------|------|
|
||||||
|
| Consultant_interviewer | 7595077233002840079 | 轻医美咨询师面试官 | ✅ 生产环境 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
coze-workflows/
|
||||||
|
├── README.md # 本文件
|
||||||
|
├── 工作流分析.md # 工作流与架构对照分析
|
||||||
|
└── Chatflow-Consultant_interviewer-draft-268/
|
||||||
|
├── MANIFEST.yml # 工作流元信息
|
||||||
|
└── workflow/
|
||||||
|
└── Consultant_interviewer-draft.yaml # 工作流定义文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作流核心信息
|
||||||
|
|
||||||
|
### Consultant_interviewer
|
||||||
|
|
||||||
|
**用途**:轻医美咨询师岗位 AI 初试面试官
|
||||||
|
|
||||||
|
**核心能力**:
|
||||||
|
- 4 维度面试评估(销售技能、销售观、素质项、求职动机)
|
||||||
|
- STAR 行为面试法提问
|
||||||
|
- 自动评分与报告生成
|
||||||
|
- 断点续传(支持中途退出后恢复)
|
||||||
|
|
||||||
|
**关联资源**:
|
||||||
|
| 资源 | ID |
|
||||||
|
|------|-----|
|
||||||
|
| Bot ID | 7595113005181386792 |
|
||||||
|
| Workflow ID | 7595077233002840079 |
|
||||||
|
| Database ID | 7595077053909712922 |
|
||||||
|
|
||||||
|
**节点统计**:
|
||||||
|
| 类型 | 数量 |
|
||||||
|
|------|------|
|
||||||
|
| LLM 节点 | 18 |
|
||||||
|
| 问答节点 | 11 |
|
||||||
|
| 循环节点 | 5 |
|
||||||
|
| 条件分支 | 6 |
|
||||||
|
| 数据库操作 | 8 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 如何导入
|
||||||
|
|
||||||
|
1. 登录 [Coze 平台](https://www.coze.cn)
|
||||||
|
2. 进入工作空间
|
||||||
|
3. 点击「导入工作流」
|
||||||
|
4. 上传 `Chatflow-Consultant_interviewer-draft-268` 文件夹
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [工作流分析](./工作流分析.md) - 工作流与前后端架构对照
|
||||||
|
- [PRD](../docs/PRD.md) - 产品需求文档
|
||||||
|
- [技术选型](../docs/技术选型.md) - 技术栈说明
|
||||||
250
coze-workflows/工作流分析.md
Normal file
250
coze-workflows/工作流分析.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# Coze 工作流分析
|
||||||
|
|
||||||
|
> 分析 Consultant_interviewer 工作流与当前前后端架构的对应关系
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、工作流概览
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| **工作流名称** | Consultant_interviewer |
|
||||||
|
| **工作流 ID** | 7595077233002840079 |
|
||||||
|
| **模式** | Chatflow(对话流) |
|
||||||
|
| **描述** | 轻医美咨询师初试智能面试官 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、工作流核心节点
|
||||||
|
|
||||||
|
### 2.1 节点类型统计
|
||||||
|
|
||||||
|
| 节点类型 | 数量 | 说明 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `start` | 1 | 开始节点 |
|
||||||
|
| `end` | 1 | 结束节点 |
|
||||||
|
| `llm` | 18 | 大模型调用节点 |
|
||||||
|
| `question` | 11 | 问答交互节点 |
|
||||||
|
| `loop` | 5 | 循环节点 |
|
||||||
|
| `condition` | 6 | 条件分支节点 |
|
||||||
|
| `code` | 2 | 代码执行节点 |
|
||||||
|
| `output` | 4 | 输出节点 |
|
||||||
|
| `select_record` | 2 | 数据库查询节点 |
|
||||||
|
| `plugin` | 1 | 插件节点(读取简历) |
|
||||||
|
|
||||||
|
### 2.2 面试阶段流程
|
||||||
|
|
||||||
|
```
|
||||||
|
开始
|
||||||
|
│
|
||||||
|
├─ 查询数据(检查 session_id 是否存在)
|
||||||
|
│
|
||||||
|
├─ 生成会话ID(代码节点)
|
||||||
|
│
|
||||||
|
├─ 条件分支:根据 current_stage 决定进入哪个阶段
|
||||||
|
│ ├─ stage < 20 → 信息收集阶段
|
||||||
|
│ ├─ stage >= 20 → 销售技能循环
|
||||||
|
│ ├─ stage >= 30 → 销售观循环
|
||||||
|
│ ├─ stage >= 40 → 素质项循环
|
||||||
|
│ └─ stage >= 50 → 求职动机识别
|
||||||
|
│
|
||||||
|
├─ 【阶段0】姓名收集 → 上传简历 → 读取简历 → 新增数据
|
||||||
|
│
|
||||||
|
├─ 【阶段1】打招呼、介绍面试流程
|
||||||
|
│
|
||||||
|
├─ 【阶段2】销售技能提问循环(1轮)
|
||||||
|
│ ├─ 销售技巧提问(LLM)
|
||||||
|
│ ├─ 问答(用户回复)
|
||||||
|
│ ├─ 追问(LLM)
|
||||||
|
│ ├─ 问答(用户回复)
|
||||||
|
│ └─ 新增数据(保存面试记录)
|
||||||
|
│
|
||||||
|
├─ 【阶段3】销售观提问循环(1轮)
|
||||||
|
│ ├─ 销售观提问(LLM)
|
||||||
|
│ ├─ 问答 → 追问 → 问答
|
||||||
|
│ └─ 新增数据
|
||||||
|
│
|
||||||
|
├─ 【阶段4】素质项提问循环(1轮)
|
||||||
|
│ ├─ 读取素质项侧重(数据库)
|
||||||
|
│ ├─ 素质项提问(LLM)
|
||||||
|
│ ├─ 问答 → 追问 → 问答
|
||||||
|
│ └─ 新增数据
|
||||||
|
│
|
||||||
|
├─ 【阶段5】求职动机识别循环(1轮)
|
||||||
|
│ ├─ 求职动机提问(LLM)
|
||||||
|
│ ├─ 问答 → 追问 → 问答
|
||||||
|
│ └─ 新增数据
|
||||||
|
│
|
||||||
|
├─ 【评分阶段】
|
||||||
|
│ ├─ 销售技能评分(LLM)
|
||||||
|
│ ├─ 销售观、服务观评分(LLM)
|
||||||
|
│ ├─ 素质项评分(LLM)
|
||||||
|
│ └─ 更新数据(保存评分结果)
|
||||||
|
│
|
||||||
|
├─ 【报告阶段】
|
||||||
|
│ ├─ 求职动机总结(LLM)
|
||||||
|
│ ├─ 未来发展规划(LLM)
|
||||||
|
│ ├─ 背调问题清单(LLM)
|
||||||
|
│ ├─ 直觉验证(LLM)
|
||||||
|
│ └─ 任用风险提示(LLM)
|
||||||
|
│
|
||||||
|
└─ 【反问阶段】回答面试者问题循环
|
||||||
|
├─ 意图识别
|
||||||
|
├─ 回答面试者问题(LLM)
|
||||||
|
└─ 终止循环判断
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、与当前架构的对应关系
|
||||||
|
|
||||||
|
### 3.1 前端需负责的部分
|
||||||
|
|
||||||
|
| 前端功能 | 工作流对应 | 当前状态 |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| 欢迎页 | 无 | ✅ 已有页面骨架 |
|
||||||
|
| 姓名输入 | `question` 节点 "姓名" | ✅ 已有 info.vue |
|
||||||
|
| 简历上传 | `question` 节点 "上传简历" → `plugin` "读取简历" | ✅ 已有上传逻辑 |
|
||||||
|
| 模拟来电 | 无(纯前端效果) | ✅ 已有 call.vue |
|
||||||
|
| 语音通话 | **整个对话流由 Coze RTC 处理** | ⚠️ 需对接 RTC SDK |
|
||||||
|
| 面试结束 | `end` 节点 | ✅ 已有 complete.vue |
|
||||||
|
|
||||||
|
### 3.2 后端需负责的部分
|
||||||
|
|
||||||
|
| 后端功能 | 工作流对应 | 当前状态 |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| 简历上传到 Coze | `POST /v1/files/upload` | ✅ 已实现 coze_service |
|
||||||
|
| 创建语音房间 | `POST /v1/audio/rooms` | ✅ 已实现 coze_service |
|
||||||
|
| 候选人数据查询 | 数据库节点 `select_record` | ⚠️ Coze 无 REST API |
|
||||||
|
| 候选人数据新增 | 数据库节点 `insert_record` | ⚠️ 由工作流自动处理 |
|
||||||
|
|
||||||
|
### 3.3 完全由 Coze 工作流处理的部分
|
||||||
|
|
||||||
|
以下功能**完全在 Coze 工作流内部完成**,前后端无需实现:
|
||||||
|
|
||||||
|
| 功能 | 工作流节点 |
|
||||||
|
|------|-----------|
|
||||||
|
| 打招呼、介绍面试流程 | LLM "打招呼、介绍面试流程" |
|
||||||
|
| 销售技能提问 | Loop "销售技能提问循环" |
|
||||||
|
| 销售观提问 | Loop "销售观提问循环" |
|
||||||
|
| 素质项提问 | Loop "素质项提问循环" |
|
||||||
|
| 求职动机识别 | Loop "求职动机识别提问循环" |
|
||||||
|
| 追问逻辑 | 各循环内的 "追问" LLM 节点 |
|
||||||
|
| 销售技能评分 | LLM "销售技能评分" |
|
||||||
|
| 销售观评分 | LLM "销售观、服务观评分" |
|
||||||
|
| 素质项评分 | LLM "素质项评分" |
|
||||||
|
| 求职动机总结 | LLM "求职动机总结" |
|
||||||
|
| 未来发展规划 | LLM "未来发展规划" |
|
||||||
|
| 背调问题清单 | LLM "背调问题清单" |
|
||||||
|
| 直觉验证 | LLM "直觉验证" |
|
||||||
|
| 任用风险提示 | LLM "任用风险提示" |
|
||||||
|
| 回答面试者问题 | Loop "回答面试者问题循环" |
|
||||||
|
| 面试记录保存 | 各 "新增数据" 节点 |
|
||||||
|
| 评分结果保存 | "更新数据" 节点 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、关键发现
|
||||||
|
|
||||||
|
### 4.1 ✅ 匹配的设计
|
||||||
|
|
||||||
|
1. **面试维度一致**:工作流的 4 维度(销售技能、销售观、素质项、求职动机)与 PRD 一致
|
||||||
|
2. **简历上传流程一致**:前端上传 → 后端调用 Coze API → 工作流解析
|
||||||
|
3. **语音房间创建**:后端调用 `POST /v1/audio/rooms` 创建房间,前端使用 RTC SDK 加入
|
||||||
|
|
||||||
|
### 4.2 ⚠️ 需调整的设计
|
||||||
|
|
||||||
|
#### (1)数据库访问问题
|
||||||
|
|
||||||
|
**问题**:Coze 数据库不提供直接的 REST API 查询接口
|
||||||
|
|
||||||
|
**影响**:管理后台的"候选人列表"、"候选人详情"功能无法直接实现
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
| 方案 | 说明 | 推荐度 |
|
||||||
|
|------|------|--------|
|
||||||
|
| A | 工作流增加 Webhook 节点,面试结束后推送数据到后端 | ⭐⭐⭐ |
|
||||||
|
| B | 自建数据库(SQLite/MySQL),前端上传时同步写入 | ⭐⭐⭐ |
|
||||||
|
| C | 暂时搁置管理后台(当前选择) | ⭐⭐ |
|
||||||
|
|
||||||
|
#### (2)会话断点续传
|
||||||
|
|
||||||
|
**工作流逻辑**:
|
||||||
|
- 工作流通过 `current_stage` 字段记录面试进度(10/20/30/40/50...)
|
||||||
|
- 用户中途退出后,可通过 `session_id` 恢复进度
|
||||||
|
|
||||||
|
**前端需支持**:
|
||||||
|
- 保存 `session_id` 到 localStorage
|
||||||
|
- 重新进入时检查是否有未完成的面试
|
||||||
|
|
||||||
|
#### (3)环境变量补充
|
||||||
|
|
||||||
|
工作流中使用的数据库 ID 需要配置:
|
||||||
|
```bash
|
||||||
|
# 已有
|
||||||
|
COZE_BOT_ID=7595113005181386792
|
||||||
|
|
||||||
|
# 需补充(工作流中的数据库)
|
||||||
|
COZE_WORKFLOW_ID=7595077233002840079
|
||||||
|
COZE_DATABASE_ID=7595077053909712922
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 ❓ 待确认事项
|
||||||
|
|
||||||
|
| 问题 | 影响范围 | 建议 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 工作流的 BOT_ID 与 WORKFLOW_ID 哪个用于创建房间? | 后端 API | 需测试确认 |
|
||||||
|
| 工作流是否支持传入 `session_id` 参数? | 断点续传 | 查看 start 节点 |
|
||||||
|
| `CONVERSATION_NAME` 参数的用途? | 会话隔离 | 可能是会话标识 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、开发优先级调整
|
||||||
|
|
||||||
|
基于工作流分析,建议调整开发重点:
|
||||||
|
|
||||||
|
### 5.1 优先完成(用户端)
|
||||||
|
|
||||||
|
1. ✅ 前端用户端 4 个页面
|
||||||
|
2. ✅ 后端 Coze API 封装(文件上传、房间创建)
|
||||||
|
3. ⚠️ 前端 RTC SDK 集成(核心功能)
|
||||||
|
4. ⚠️ 会话状态管理(session_id 本地存储)
|
||||||
|
|
||||||
|
### 5.2 暂时搁置(管理后台)
|
||||||
|
|
||||||
|
由于 Coze 数据库无 REST API,管理后台需等待以下方案之一落地:
|
||||||
|
- 工作流添加 Webhook 推送
|
||||||
|
- 自建数据库方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、RTC 对接要点
|
||||||
|
|
||||||
|
根据工作流,语音通话的核心逻辑如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 后端调用 POST /v1/audio/rooms 创建房间
|
||||||
|
请求体:{ bot_id, user_id, voice_id? }
|
||||||
|
返回:{ room_id, token, app_id, uid }
|
||||||
|
|
||||||
|
2. 前端使用 @volcengine/rtc SDK 加入房间
|
||||||
|
- 使用返回的 token 进行鉴权
|
||||||
|
- 使用返回的 app_id、room_id、uid 加入房间
|
||||||
|
|
||||||
|
3. 语音对话
|
||||||
|
- 前端采集麦克风音频
|
||||||
|
- SDK 自动推送到 Coze 工作流
|
||||||
|
- 工作流处理后返回 AI 语音
|
||||||
|
|
||||||
|
4. 面试结束
|
||||||
|
- 工作流达到 end 节点
|
||||||
|
- 可能通过事件通知前端
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更日志
|
||||||
|
|
||||||
|
| 日期 | 内容 | 操作人 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 2026-01-20 | 初始化工作流分析文档 | AI |
|
||||||
21
deploy/Dockerfile.backend
Normal file
21
deploy/Dockerfile.backend
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 后端 Python 服务
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 配置 pip 使用阿里云镜像
|
||||||
|
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||||
|
pip config set global.trusted-host mirrors.aliyun.com
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY backend/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt --timeout 120
|
||||||
|
|
||||||
|
# 复制源码
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
32
deploy/Dockerfile.frontend
Normal file
32
deploy/Dockerfile.frontend
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 前端构建
|
||||||
|
FROM node:18-alpine as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install --frozen-lockfile || pnpm install
|
||||||
|
|
||||||
|
# 复制源码
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# 生产镜像
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制 nginx 配置
|
||||||
|
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
246
deploy/bt_deploy.py
Normal file
246
deploy/bt_deploy.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
通过宝塔 API 自动部署 AI 面试系统
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 禁用 SSL 警告
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# 宝塔配置
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
|
||||||
|
# 部署配置
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
DOMAIN = "interview.test.ai.ireborn.com.cn"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, files=None):
|
||||||
|
"""调用宝塔 API"""
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if files:
|
||||||
|
response = requests.post(url, data=data, files=files, timeout=300, verify=False)
|
||||||
|
else:
|
||||||
|
response = requests.post(url, data=data, timeout=60, verify=False)
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.JSONDecodeError:
|
||||||
|
return {"status": True, "msg": response.text}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def create_directory(path):
|
||||||
|
"""创建目录"""
|
||||||
|
print(f"📁 创建目录: {path}")
|
||||||
|
result = bt_api("files?action=CreateDir", {"path": path})
|
||||||
|
if result.get("status") or "已存在" in str(result.get("msg", "")):
|
||||||
|
print(" ✅ 目录已就绪")
|
||||||
|
return True
|
||||||
|
print(f" ⚠️ {result}")
|
||||||
|
return True # 继续执行
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(local_path, remote_dir):
|
||||||
|
"""上传文件到服务器"""
|
||||||
|
filename = os.path.basename(local_path)
|
||||||
|
print(f"📤 上传文件: {filename}")
|
||||||
|
|
||||||
|
with open(local_path, 'rb') as f:
|
||||||
|
files = {'f_path': (filename, f)}
|
||||||
|
data = {'f_path': remote_dir, 'f_name': filename}
|
||||||
|
result = bt_api("files?action=upload", data, files)
|
||||||
|
|
||||||
|
if result.get("status") or result.get("msg") == "上传成功":
|
||||||
|
print(f" ✅ 上传成功")
|
||||||
|
return True
|
||||||
|
print(f" ❌ 上传失败: {result}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def exec_shell(command):
|
||||||
|
"""执行 Shell 命令"""
|
||||||
|
print(f"🔧 执行命令: {command[:80]}...")
|
||||||
|
result = bt_api("files?action=ExecShell", {"command": command})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_website(domain):
|
||||||
|
"""创建网站"""
|
||||||
|
print(f"🌐 创建网站: {domain}")
|
||||||
|
data = {
|
||||||
|
"webname": json.dumps({"domain": domain, "domainlist": [], "count": 0}),
|
||||||
|
"path": f"/www/wwwroot/{domain}",
|
||||||
|
"type_id": 0,
|
||||||
|
"type": "PHP",
|
||||||
|
"version": "00",
|
||||||
|
"port": "80",
|
||||||
|
"ps": "AI面试系统",
|
||||||
|
"ftp": "false",
|
||||||
|
"sql": "false"
|
||||||
|
}
|
||||||
|
result = bt_api("site?action=AddSite", data)
|
||||||
|
if result.get("status") or result.get("siteStatus"):
|
||||||
|
print(" ✅ 网站创建成功")
|
||||||
|
return True
|
||||||
|
if "已存在" in str(result.get("msg", "")):
|
||||||
|
print(" ⚠️ 网站已存在")
|
||||||
|
return True
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_proxy(domain, target_url):
|
||||||
|
"""设置反向代理"""
|
||||||
|
print(f"🔄 设置反向代理: {domain} -> {target_url}")
|
||||||
|
|
||||||
|
# 获取站点 ID
|
||||||
|
sites_result = bt_api("site?action=GetSitesSort")
|
||||||
|
site_id = None
|
||||||
|
if sites_result.get("data"):
|
||||||
|
for site in sites_result["data"]:
|
||||||
|
if site.get("name") == domain:
|
||||||
|
site_id = site.get("id")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not site_id:
|
||||||
|
print(" ⚠️ 未找到站点 ID,尝试继续...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 设置代理
|
||||||
|
proxy_data = {
|
||||||
|
"sitename": domain,
|
||||||
|
"proxyname": "ai-interview",
|
||||||
|
"proxydir": "/",
|
||||||
|
"proxysite": target_url,
|
||||||
|
"proxysend": "0",
|
||||||
|
"cache": "0",
|
||||||
|
"cacheTime": "1",
|
||||||
|
"subfilter": "[]",
|
||||||
|
"type": "1",
|
||||||
|
"advanced": "0"
|
||||||
|
}
|
||||||
|
result = bt_api("site?action=CreateProxy", proxy_data)
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def write_remote_file(path, content):
|
||||||
|
"""写入远程文件"""
|
||||||
|
print(f"📝 写入文件: {path}")
|
||||||
|
# 使用 base64 编码内容
|
||||||
|
encoded = base64.b64encode(content.encode()).decode()
|
||||||
|
result = bt_api("files?action=SaveFileBody", {
|
||||||
|
"path": path,
|
||||||
|
"data": content,
|
||||||
|
"encoding": "utf-8"
|
||||||
|
})
|
||||||
|
if result.get("status"):
|
||||||
|
print(" ✅ 文件写入成功")
|
||||||
|
return True
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🚀 AI 语音面试系统 - 自动部署")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 1. 创建部署目录
|
||||||
|
print("\n📦 步骤 1: 创建目录结构")
|
||||||
|
create_directory(DEPLOY_PATH)
|
||||||
|
create_directory(f"{DEPLOY_PATH}/frontend")
|
||||||
|
create_directory(f"{DEPLOY_PATH}/backend")
|
||||||
|
create_directory(f"{DEPLOY_PATH}/deploy")
|
||||||
|
create_directory(f"{DEPLOY_PATH}/deploy/nginx")
|
||||||
|
|
||||||
|
# 2. 写入配置文件
|
||||||
|
print("\n📝 步骤 2: 写入配置文件")
|
||||||
|
|
||||||
|
# .env 文件
|
||||||
|
env_content = """COZE_PAT_TOKEN=pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT
|
||||||
|
COZE_BOT_ID=7595113005181386792
|
||||||
|
COZE_WORKFLOW_A_ID=7597357422713798710
|
||||||
|
COZE_WORKFLOW_C_ID=7597376294612107318
|
||||||
|
FILE_SERVER_URL=https://files.test.ai.ireborn.com.cn
|
||||||
|
FILE_SERVER_TOKEN=ai_interview_2026_secret
|
||||||
|
"""
|
||||||
|
write_remote_file(f"{DEPLOY_PATH}/deploy/.env", env_content)
|
||||||
|
|
||||||
|
# 3. 上传部署包
|
||||||
|
print("\n📤 步骤 3: 上传部署包")
|
||||||
|
tar_path = "/Users/a111/Documents/AgentWD/projects/ai-interview-deploy.tar.gz"
|
||||||
|
if os.path.exists(tar_path):
|
||||||
|
upload_file(tar_path, "/www/wwwroot")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 部署包不存在: {tar_path}")
|
||||||
|
|
||||||
|
# 4. 解压并部署
|
||||||
|
print("\n🔧 步骤 4: 解压部署包")
|
||||||
|
commands = [
|
||||||
|
f"cd /www/wwwroot && tar -xzf ai-interview-deploy.tar.gz -C {DEPLOY_PATH}",
|
||||||
|
f"cd {DEPLOY_PATH}/deploy && cp .env .env.bak 2>/dev/null || true",
|
||||||
|
]
|
||||||
|
for cmd in commands:
|
||||||
|
exec_shell(cmd)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 5. 启动 Docker
|
||||||
|
print("\n🐳 步骤 5: 启动 Docker 容器")
|
||||||
|
docker_commands = [
|
||||||
|
f"cd {DEPLOY_PATH}/deploy && docker-compose down 2>/dev/null || true",
|
||||||
|
f"cd {DEPLOY_PATH}/deploy && docker-compose up -d --build",
|
||||||
|
]
|
||||||
|
for cmd in docker_commands:
|
||||||
|
print(f" 执行: {cmd}")
|
||||||
|
result = exec_shell(cmd)
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# 6. 创建网站和反向代理
|
||||||
|
print("\n🌐 步骤 6: 配置网站")
|
||||||
|
create_website(DOMAIN)
|
||||||
|
time.sleep(2)
|
||||||
|
set_proxy(DOMAIN, "http://127.0.0.1:3000")
|
||||||
|
|
||||||
|
# 7. 检查状态
|
||||||
|
print("\n🔍 步骤 7: 检查部署状态")
|
||||||
|
result = exec_shell("docker ps --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'")
|
||||||
|
print(f" 容器状态: {result}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("✅ 部署完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print(f"🌐 访问地址:")
|
||||||
|
print(f" 用户端: http://{DOMAIN}")
|
||||||
|
print(f" 管理后台: http://{DOMAIN}/admin")
|
||||||
|
print()
|
||||||
|
print("⚠️ 如需 HTTPS,请在宝塔面板中为该网站申请 SSL 证书")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
132
deploy/build_only.py
Normal file
132
deploy/build_only.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
仅执行构建命令
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:2000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(shell_body, task_name, wait_time=30):
|
||||||
|
# 先清理旧任务
|
||||||
|
result = bt_api("crontab?action=GetCrontab", {"page": 1, "limit": 100})
|
||||||
|
if isinstance(result, dict) and result.get("data"):
|
||||||
|
for task in result["data"]:
|
||||||
|
if isinstance(task, dict) and ("upload_" in task.get("name", "") or "build_" in task.get("name", "")):
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": task["id"]})
|
||||||
|
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
})
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f"创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
cron_id = result["id"]
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=600)
|
||||||
|
print(f"任务 {cron_id} 启动,等待 {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🐳 构建 Docker 容器")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 先清理磁盘空间
|
||||||
|
print("\n🧹 步骤 1: 清理磁盘空间...")
|
||||||
|
cleanup_script = """#!/bin/bash
|
||||||
|
echo "清理 Docker..."
|
||||||
|
docker system prune -af --volumes 2>/dev/null || true
|
||||||
|
docker builder prune -af 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "清理宝塔日志..."
|
||||||
|
rm -rf /www/wwwlogs/*.log 2>/dev/null || true
|
||||||
|
find /www/server/panel/logs -name "*.log" -mtime +1 -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "磁盘使用:"
|
||||||
|
df -h /
|
||||||
|
"""
|
||||||
|
result = run_task(cleanup_script, "cleanup", wait_time=30)
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print(result["msg"][:1000])
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
print("\n🐳 步骤 2: 构建并启动...")
|
||||||
|
build_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
|
||||||
|
echo "目录检查:"
|
||||||
|
ls -la {DEPLOY_PATH}/frontend/ | head -5
|
||||||
|
ls -la {DEPLOY_PATH}/backend/ | head -5
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "构建并启动..."
|
||||||
|
docker-compose up -d --build 2>&1
|
||||||
|
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "容器状态:"
|
||||||
|
docker ps -a --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "后端日志:"
|
||||||
|
docker logs --tail 30 ai-interview-backend 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "测试:"
|
||||||
|
curl -s http://127.0.0.1:8000/health 2>&1 || echo "后端未响应"
|
||||||
|
echo ""
|
||||||
|
curl -s -o /dev/null -w "前端: %{{http_code}}" http://127.0.0.1:3000 2>&1
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_task(build_script, "build", wait_time=180)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n📋 构建结果:")
|
||||||
|
print("-" * 60)
|
||||||
|
print(result["msg"])
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🌐 http://interview.test.ai.ireborn.com.cn")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
124
deploy/check_docker.py
Normal file
124
deploy/check_docker.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
检查 Docker 容器状态
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# 禁用 SSL 警告
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# 宝塔配置
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
"""调用宝塔 API"""
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:2000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_and_get_log(shell_body, task_name):
|
||||||
|
"""创建计划任务,运行并获取日志"""
|
||||||
|
# 1. 创建任务
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"hour": "",
|
||||||
|
"minute": "",
|
||||||
|
"week": "",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
"sName": "",
|
||||||
|
"backupTo": "",
|
||||||
|
"save": "",
|
||||||
|
"urladdress": ""
|
||||||
|
})
|
||||||
|
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f"创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cron_id = result["id"]
|
||||||
|
print(f"任务创建成功,ID: {cron_id}")
|
||||||
|
|
||||||
|
# 2. 执行任务
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=120)
|
||||||
|
print("任务已启动,等待执行...")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# 3. 获取日志
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
|
||||||
|
# 4. 删除任务
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🔍 检查 Docker 容器状态")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 检查命令
|
||||||
|
check_script = f"""#!/bin/bash
|
||||||
|
echo "========== Docker 容器状态 =========="
|
||||||
|
docker ps -a
|
||||||
|
echo ""
|
||||||
|
echo "========== Docker Compose 状态 =========="
|
||||||
|
cd {DEPLOY_PATH}/deploy && docker-compose ps 2>&1 || echo "docker-compose 未运行"
|
||||||
|
echo ""
|
||||||
|
echo "========== 后端容器日志 (最后 50 行) =========="
|
||||||
|
docker logs --tail 50 ai-interview-backend 2>&1 || echo "后端容器不存在"
|
||||||
|
echo ""
|
||||||
|
echo "========== 检查端口 =========="
|
||||||
|
netstat -tlnp | grep -E ':(3000|8000)' || ss -tlnp | grep -E ':(3000|8000)' || echo "端口未监听"
|
||||||
|
echo ""
|
||||||
|
echo "========== 测试本地服务 =========="
|
||||||
|
curl -s http://127.0.0.1:8000/health 2>&1 || echo "后端 8000 端口无响应"
|
||||||
|
curl -s -o /dev/null -w "前端 3000 端口: HTTP %{{http_code}}" http://127.0.0.1:3000 2>&1 || echo "前端 3000 端口无响应"
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_and_get_log(check_script, f"check_docker_{int(time.time())}")
|
||||||
|
|
||||||
|
if result:
|
||||||
|
print("\n📋 执行结果:")
|
||||||
|
print("-" * 60)
|
||||||
|
if isinstance(result, dict) and result.get("msg"):
|
||||||
|
print(result["msg"])
|
||||||
|
else:
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
133
deploy/cleanup_and_build.py
Normal file
133
deploy/cleanup_and_build.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
清理宝塔旧任务后再构建
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:2000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_tasks():
|
||||||
|
"""清理所有旧的计划任务"""
|
||||||
|
print("🧹 清理旧计划任务...")
|
||||||
|
result = bt_api("crontab?action=GetCrontab", {"page": 1, "limit": 100})
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
if isinstance(result, list):
|
||||||
|
for task in result:
|
||||||
|
if isinstance(task, dict) and task.get("id"):
|
||||||
|
name = task.get("name", "")
|
||||||
|
# 删除我们创建的临时任务
|
||||||
|
if any(x in name for x in ["upload_", "build_", "cleanup", "restart", "check", "create", "update", "rebuild"]):
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": task["id"]})
|
||||||
|
deleted += 1
|
||||||
|
print(f" 删除: {name}")
|
||||||
|
elif isinstance(result, dict) and result.get("data"):
|
||||||
|
for task in result["data"]:
|
||||||
|
if isinstance(task, dict) and task.get("id"):
|
||||||
|
name = task.get("name", "")
|
||||||
|
if any(x in name for x in ["upload_", "build_", "cleanup", "restart", "check", "create", "update", "rebuild"]):
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": task["id"]})
|
||||||
|
deleted += 1
|
||||||
|
print(f" 删除: {name}")
|
||||||
|
|
||||||
|
print(f" 已删除 {deleted} 个旧任务")
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(shell_body, task_name, wait_time=30):
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
})
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f" 创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
cron_id = result["id"]
|
||||||
|
print(f" 任务 {cron_id} 已创建")
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=600)
|
||||||
|
print(f" 执行中,等待 {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🚀 清理并构建")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. 清理旧任务
|
||||||
|
cleanup_old_tasks()
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# 2. 构建
|
||||||
|
print("\n🐳 构建 Docker 容器...")
|
||||||
|
build_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
|
||||||
|
# 清理空间
|
||||||
|
docker system prune -af 2>/dev/null || true
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
docker-compose up -d --build 2>&1
|
||||||
|
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
echo "容器状态:"
|
||||||
|
docker ps -a --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "后端日志:"
|
||||||
|
docker logs --tail 20 ai-interview-backend 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "测试:"
|
||||||
|
curl -s http://127.0.0.1:8000/health || echo "后端未响应"
|
||||||
|
curl -s -o /dev/null -w " 前端:%{{http_code}}" http://127.0.0.1:3000
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_task(build_script, "deploy", wait_time=180)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n📋 结果:")
|
||||||
|
print("-" * 60)
|
||||||
|
print(result["msg"])
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🌐 http://interview.test.ai.ireborn.com.cn")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
136
deploy/cleanup_bt.py
Normal file
136
deploy/cleanup_bt.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
深度清理宝塔面板
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:2000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🧹 深度清理宝塔面板")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. 删除所有计划任务
|
||||||
|
print("\n1. 清理计划任务...")
|
||||||
|
result = bt_api("crontab?action=GetCrontab", {"page": 1, "limit": 100})
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
tasks_to_delete = []
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
tasks_to_delete = result
|
||||||
|
elif isinstance(result, dict) and result.get("data"):
|
||||||
|
tasks_to_delete = result["data"]
|
||||||
|
|
||||||
|
for task in tasks_to_delete:
|
||||||
|
if isinstance(task, dict) and task.get("id"):
|
||||||
|
name = task.get("name", "")
|
||||||
|
# 删除所有临时任务
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": task["id"]})
|
||||||
|
deleted += 1
|
||||||
|
print(f" 删除: {name} (ID: {task['id']})")
|
||||||
|
|
||||||
|
print(f" 共删除 {deleted} 个任务")
|
||||||
|
|
||||||
|
# 2. 清理计划任务日志
|
||||||
|
print("\n2. 清理任务日志...")
|
||||||
|
result = bt_api("crontab?action=DelLogs")
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
|
||||||
|
# 3. 清理面板日志
|
||||||
|
print("\n3. 清理面板日志...")
|
||||||
|
result = bt_api("system?action=ClearCache")
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
|
||||||
|
# 4. 尝试压缩数据库
|
||||||
|
print("\n4. 压缩数据库...")
|
||||||
|
result = bt_api("system?action=ReDatabase")
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# 5. 再次尝试创建任务
|
||||||
|
print("\n5. 测试创建任务...")
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": "test_task",
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": "echo test",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.get("status") and result.get("id"):
|
||||||
|
print(f" ✅ 成功! 任务ID: {result['id']}")
|
||||||
|
# 删除测试任务
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": result["id"]})
|
||||||
|
|
||||||
|
# 现在执行构建
|
||||||
|
print("\n6. 执行构建...")
|
||||||
|
build_result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": "deploy_docker",
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
docker system prune -af 2>/dev/null || true
|
||||||
|
docker-compose up -d --build 2>&1
|
||||||
|
sleep 15
|
||||||
|
docker ps -a
|
||||||
|
docker logs --tail 20 ai-interview-backend 2>&1
|
||||||
|
curl -s http://127.0.0.1:8000/health || echo "后端未响应"
|
||||||
|
""",
|
||||||
|
})
|
||||||
|
|
||||||
|
if build_result.get("id"):
|
||||||
|
cron_id = build_result["id"]
|
||||||
|
print(f" 任务 {cron_id} 已创建,开始执行...")
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=600)
|
||||||
|
print(" 等待 180 秒...")
|
||||||
|
time.sleep(180)
|
||||||
|
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
|
||||||
|
if log_result and log_result.get("msg"):
|
||||||
|
print("\n📋 构建结果:")
|
||||||
|
print("-" * 60)
|
||||||
|
print(log_result["msg"])
|
||||||
|
else:
|
||||||
|
print(f" ❌ 仍然失败: {result}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🌐 http://interview.test.ai.ireborn.com.cn")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
183
deploy/create_files.py
Normal file
183
deploy/create_files.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
通过宝塔计划任务创建文件
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# 禁用 SSL 警告
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# 宝塔配置
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
LOCAL_BACKEND = "/Users/a111/Documents/AgentWD/projects/011-ai-interview-2601/backend"
|
||||||
|
LOCAL_DEPLOY = "/Users/a111/Documents/AgentWD/projects/011-ai-interview-2601/deploy"
|
||||||
|
LOCAL_FRONTEND = "/Users/a111/Documents/AgentWD/projects/011-ai-interview-2601/frontend"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
"""调用宝塔 API"""
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:1000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(shell_body, task_name, wait_time=30):
|
||||||
|
"""创建计划任务并执行"""
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"hour": "",
|
||||||
|
"minute": "",
|
||||||
|
"week": "",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
"sName": "",
|
||||||
|
"backupTo": "",
|
||||||
|
"save": "",
|
||||||
|
"urladdress": ""
|
||||||
|
})
|
||||||
|
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f" 创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cron_id = result["id"]
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=300)
|
||||||
|
print(f" 任务 {cron_id} 已启动,等待 {wait_time} 秒...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def create_file_via_shell(remote_path, content):
|
||||||
|
"""通过 shell 命令创建文件"""
|
||||||
|
# 使用 base64 编码内容以避免特殊字符问题
|
||||||
|
encoded = base64.b64encode(content.encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
# 确保目录存在,然后写入文件
|
||||||
|
dir_path = os.path.dirname(remote_path)
|
||||||
|
shell_script = f"""#!/bin/bash
|
||||||
|
mkdir -p {dir_path}
|
||||||
|
echo '{encoded}' | base64 -d > {remote_path}
|
||||||
|
echo "文件已创建: {remote_path}"
|
||||||
|
ls -la {remote_path}
|
||||||
|
"""
|
||||||
|
return shell_script
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("📤 创建所有必需文件")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 读取所有需要上传的文件
|
||||||
|
files_to_create = []
|
||||||
|
|
||||||
|
# 1. requirements.txt
|
||||||
|
with open(os.path.join(LOCAL_BACKEND, "requirements.txt"), 'r') as f:
|
||||||
|
files_to_create.append((f"{DEPLOY_PATH}/backend/requirements.txt", f.read()))
|
||||||
|
|
||||||
|
# 2. Dockerfile.frontend
|
||||||
|
with open(os.path.join(LOCAL_DEPLOY, "Dockerfile.frontend"), 'r') as f:
|
||||||
|
files_to_create.append((f"{DEPLOY_PATH}/deploy/Dockerfile.frontend", f.read()))
|
||||||
|
|
||||||
|
# 批量创建文件
|
||||||
|
print("📁 创建目录结构和文件...")
|
||||||
|
|
||||||
|
# 合并所有文件创建命令
|
||||||
|
all_commands = f"""#!/bin/bash
|
||||||
|
mkdir -p {DEPLOY_PATH}/backend
|
||||||
|
mkdir -p {DEPLOY_PATH}/backend/app/routers
|
||||||
|
mkdir -p {DEPLOY_PATH}/backend/app/services
|
||||||
|
mkdir -p {DEPLOY_PATH}/deploy/nginx
|
||||||
|
mkdir -p {DEPLOY_PATH}/frontend
|
||||||
|
mkdir -p {DEPLOY_PATH}/deploy/uploads
|
||||||
|
"""
|
||||||
|
|
||||||
|
for remote_path, content in files_to_create:
|
||||||
|
encoded = base64.b64encode(content.encode('utf-8')).decode('utf-8')
|
||||||
|
all_commands += f"""
|
||||||
|
echo "创建: {remote_path}"
|
||||||
|
echo '{encoded}' | base64 -d > {remote_path}
|
||||||
|
"""
|
||||||
|
|
||||||
|
all_commands += f"""
|
||||||
|
echo ""
|
||||||
|
echo "文件列表:"
|
||||||
|
ls -la {DEPLOY_PATH}/backend/
|
||||||
|
ls -la {DEPLOY_PATH}/deploy/
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_task(all_commands, f"create_files_{int(time.time())}", wait_time=15)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n📋 执行结果:")
|
||||||
|
print(result["msg"][:1500])
|
||||||
|
|
||||||
|
# 3. 重新构建 Docker
|
||||||
|
print("\n🐳 重新构建 Docker 容器...")
|
||||||
|
rebuild_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
echo "停止旧容器..."
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
echo "重新构建..."
|
||||||
|
docker-compose up -d --build 2>&1
|
||||||
|
sleep 15
|
||||||
|
echo ""
|
||||||
|
echo "容器状态:"
|
||||||
|
docker ps --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
echo ""
|
||||||
|
echo "后端容器日志:"
|
||||||
|
docker logs --tail 30 ai-interview-backend 2>&1
|
||||||
|
echo ""
|
||||||
|
echo "测试后端:"
|
||||||
|
curl -s http://127.0.0.1:8000/health 2>&1 || echo "后端未响应"
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_task(rebuild_script, f"rebuild_{int(time.time())}", wait_time=90)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n📋 构建结果:")
|
||||||
|
print("-" * 60)
|
||||||
|
print(result["msg"][:3000])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("✅ 完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
81
deploy/deploy.sh
Normal file
81
deploy/deploy.sh
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# AI 面试系统部署脚本
|
||||||
|
# 使用方法: bash deploy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "AI 语音面试系统 - Docker 部署"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
DEPLOY_DIR="/www/wwwroot/ai-interview"
|
||||||
|
DOMAIN="interview.test.ai.ireborn.com.cn"
|
||||||
|
|
||||||
|
# 检查 Docker
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker 未安装,请先安装 Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose 未安装,请先安装"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Docker 环境检查通过"
|
||||||
|
|
||||||
|
# 创建部署目录
|
||||||
|
echo "📁 创建部署目录..."
|
||||||
|
mkdir -p $DEPLOY_DIR
|
||||||
|
cd $DEPLOY_DIR
|
||||||
|
|
||||||
|
# 检查 .env 文件
|
||||||
|
if [ ! -f "deploy/.env" ]; then
|
||||||
|
echo "⚠️ 未找到 .env 文件,正在创建..."
|
||||||
|
mkdir -p deploy
|
||||||
|
cat > deploy/.env << 'EOF'
|
||||||
|
# Coze 配置
|
||||||
|
COZE_PAT_TOKEN=pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT
|
||||||
|
COZE_BOT_ID=7595113005181386792
|
||||||
|
|
||||||
|
# 工作流 ID
|
||||||
|
COZE_WORKFLOW_A_ID=7597357422713798710
|
||||||
|
COZE_WORKFLOW_C_ID=7597376294612107318
|
||||||
|
|
||||||
|
# 文件服务器
|
||||||
|
FILE_SERVER_URL=https://files.test.ai.ireborn.com.cn
|
||||||
|
FILE_SERVER_TOKEN=ai_interview_2026_secret
|
||||||
|
EOF
|
||||||
|
echo "✅ .env 文件已创建"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建并启动
|
||||||
|
echo "🐳 构建 Docker 镜像..."
|
||||||
|
cd deploy
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
echo "⏳ 等待服务启动..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 检查服务状态
|
||||||
|
echo "🔍 检查服务状态..."
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "✅ 部署完成!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "访问地址:"
|
||||||
|
echo " 用户端: http://$DOMAIN"
|
||||||
|
echo " 管理后台: http://$DOMAIN/admin"
|
||||||
|
echo " 后端 API: http://$DOMAIN/api"
|
||||||
|
echo ""
|
||||||
|
echo "容器状态:"
|
||||||
|
docker-compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
echo ""
|
||||||
|
echo "查看日志: docker-compose logs -f"
|
||||||
|
echo "=========================================="
|
||||||
39
deploy/docker-compose.yml
Normal file
39
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile.frontend
|
||||||
|
container_name: ai-interview-frontend
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- ai-interview-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile.backend
|
||||||
|
container_name: ai-interview-backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- COZE_PAT_TOKEN=${COZE_PAT_TOKEN}
|
||||||
|
- COZE_BOT_ID=${COZE_BOT_ID}
|
||||||
|
- COZE_WORKFLOW_A_ID=${COZE_WORKFLOW_A_ID}
|
||||||
|
- COZE_WORKFLOW_C_ID=${COZE_WORKFLOW_C_ID}
|
||||||
|
- FILE_SERVER_URL=${FILE_SERVER_URL}
|
||||||
|
- FILE_SERVER_TOKEN=${FILE_SERVER_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
networks:
|
||||||
|
- ai-interview-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ai-interview-network:
|
||||||
|
driver: bridge
|
||||||
11
deploy/env.example
Normal file
11
deploy/env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Coze 配置
|
||||||
|
COZE_PAT_TOKEN=pat_xxx
|
||||||
|
COZE_BOT_ID=7595113005181386792
|
||||||
|
|
||||||
|
# 工作流 ID
|
||||||
|
COZE_WORKFLOW_A_ID=7597357422713798710
|
||||||
|
COZE_WORKFLOW_C_ID=7597376294612107318
|
||||||
|
|
||||||
|
# 文件服务器
|
||||||
|
FILE_SERVER_URL=https://files.test.ai.ireborn.com.cn
|
||||||
|
FILE_SERVER_TOKEN=ai_interview_2026_secret
|
||||||
21
deploy/env.production
Normal file
21
deploy/env.production
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# AI Interview 后端环境变量
|
||||||
|
# ============================================
|
||||||
|
# 部署步骤:
|
||||||
|
# 1. 复制此文件到服务器: /www/wwwroot/ai-interview/deploy/.env
|
||||||
|
# 2. 或者在服务器上执行: cp env.production .env
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Coze 配置
|
||||||
|
COZE_PAT_TOKEN=pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT
|
||||||
|
COZE_BOT_ID=7595113005181386792
|
||||||
|
|
||||||
|
# 工作流 ID
|
||||||
|
COZE_WORKFLOW_A_ID=7597357422713798710
|
||||||
|
COZE_WORKFLOW_C_ID=7597376294612107318
|
||||||
|
|
||||||
|
# 文件服务器
|
||||||
|
FILE_SERVER_URL=https://files.test.ai.ireborn.com.cn
|
||||||
|
FILE_SERVER_TOKEN=ai_interview_2026_secret
|
||||||
|
|
||||||
|
# CORS 允许的域名(逗号分隔)
|
||||||
|
CORS_ORIGINS=http://interview.test.ai.ireborn.com.cn,https://interview.test.ai.ireborn.com.cn,http://localhost:5173
|
||||||
211
deploy/fix_deploy.py
Normal file
211
deploy/fix_deploy.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
修复部署 - 上传配置文件并重启服务
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 禁用 SSL 警告
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# 宝塔配置
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
|
||||||
|
# 部署配置
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
DOMAIN = "interview.test.ai.ireborn.com.cn"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
"""调用宝塔 API"""
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.JSONDecodeError:
|
||||||
|
return {"status": True, "msg": response.text[:500]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def write_remote_file(path, content):
|
||||||
|
"""写入远程文件"""
|
||||||
|
print(f"📝 写入文件: {path}")
|
||||||
|
result = bt_api("files?action=SaveFileBody", {
|
||||||
|
"path": path,
|
||||||
|
"data": content,
|
||||||
|
"encoding": "utf-8"
|
||||||
|
})
|
||||||
|
if result.get("status"):
|
||||||
|
print(" ✅ 文件写入成功")
|
||||||
|
return True
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
return result.get("status", False)
|
||||||
|
|
||||||
|
|
||||||
|
def exec_shell(command, timeout=300):
|
||||||
|
"""执行 Shell 命令 - 使用宝塔终端 API"""
|
||||||
|
print(f"🔧 执行: {command[:80]}...")
|
||||||
|
|
||||||
|
# 尝试多个可能的 API 端点
|
||||||
|
endpoints = [
|
||||||
|
("deployment?action=ExecCommand", {"command": command}),
|
||||||
|
("plugin?action=a&s=deployment&name=exec_command", {"command": command}),
|
||||||
|
("crontab?action=GetDataList", {}), # 先获取计划任务列表
|
||||||
|
]
|
||||||
|
|
||||||
|
# 方法1: 使用 files 接口执行命令(某些宝塔版本支持)
|
||||||
|
result = bt_api("files?action=ExecCommand", {"command": command}, timeout=timeout)
|
||||||
|
if result.get("status") or "404" not in str(result.get("msg", "")):
|
||||||
|
print(f" 结果: {str(result)[:200]}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 方法2: 创建临时脚本并执行
|
||||||
|
script_path = "/tmp/bt_exec_cmd.sh"
|
||||||
|
write_result = bt_api("files?action=SaveFileBody", {
|
||||||
|
"path": script_path,
|
||||||
|
"data": f"#!/bin/bash\n{command}\n",
|
||||||
|
"encoding": "utf-8"
|
||||||
|
})
|
||||||
|
|
||||||
|
if write_result.get("status"):
|
||||||
|
# 设置执行权限并运行
|
||||||
|
result = bt_api("files?action=ExecCommand", {"command": f"chmod +x {script_path} && {script_path}"}, timeout=timeout)
|
||||||
|
print(f" 结果: {str(result)[:200]}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
print(f" ⚠️ 无法执行命令,请手动在服务器执行")
|
||||||
|
return {"status": False, "msg": "需要手动执行"}
|
||||||
|
|
||||||
|
|
||||||
|
def restart_nginx():
|
||||||
|
"""重启 Nginx"""
|
||||||
|
print("🔄 重启 Nginx...")
|
||||||
|
result = bt_api("system?action=ServiceAdmin", {
|
||||||
|
"name": "nginx",
|
||||||
|
"type": "restart"
|
||||||
|
})
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🔧 AI 面试系统 - 修复部署")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 1. 写入 .env 文件
|
||||||
|
print("\n📝 步骤 1: 写入环境变量文件")
|
||||||
|
env_content = """# AI Interview 后端环境变量
|
||||||
|
COZE_PAT_TOKEN=pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT
|
||||||
|
COZE_BOT_ID=7595113005181386792
|
||||||
|
COZE_WORKFLOW_A_ID=7597357422713798710
|
||||||
|
COZE_WORKFLOW_C_ID=7597376294612107318
|
||||||
|
FILE_SERVER_URL=https://files.test.ai.ireborn.com.cn
|
||||||
|
FILE_SERVER_TOKEN=ai_interview_2026_secret
|
||||||
|
CORS_ORIGINS=http://interview.test.ai.ireborn.com.cn,https://interview.test.ai.ireborn.com.cn,http://localhost:5173
|
||||||
|
"""
|
||||||
|
write_remote_file(f"{DEPLOY_PATH}/deploy/.env", env_content)
|
||||||
|
|
||||||
|
# 2. 写入 Nginx 配置
|
||||||
|
print("\n📝 步骤 2: 写入 Nginx 反向代理配置")
|
||||||
|
nginx_config = """server {
|
||||||
|
listen 80;
|
||||||
|
server_name interview.test.ai.ireborn.com.cn;
|
||||||
|
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/interview.test.ai.ireborn.com.cn.log;
|
||||||
|
error_log /www/wwwlogs/interview.test.ai.ireborn.com.cn.error.log;
|
||||||
|
|
||||||
|
# 前端 - 代理到 Docker 前端容器 (3000 端口)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API - 代理到 Docker 后端容器 (8000 端口)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000/api/;
|
||||||
|
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;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_connect_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查端点
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:8000/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
write_remote_file(f"/www/server/panel/vhost/nginx/{DOMAIN}.conf", nginx_config)
|
||||||
|
|
||||||
|
# 3. 写入 Docker 重启脚本
|
||||||
|
print("\n📝 步骤 3: 写入 Docker 重启脚本")
|
||||||
|
restart_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
docker-compose up -d --build
|
||||||
|
sleep 5
|
||||||
|
docker ps
|
||||||
|
"""
|
||||||
|
write_remote_file(f"{DEPLOY_PATH}/restart_docker.sh", restart_script)
|
||||||
|
|
||||||
|
# 4. 重启 Nginx
|
||||||
|
print("\n🔄 步骤 4: 重启 Nginx")
|
||||||
|
restart_nginx()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("✅ 配置文件已上传!")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print("⚠️ 请在服务器上手动执行以下命令重启 Docker:")
|
||||||
|
print()
|
||||||
|
print(f" chmod +x {DEPLOY_PATH}/restart_docker.sh")
|
||||||
|
print(f" {DEPLOY_PATH}/restart_docker.sh")
|
||||||
|
print()
|
||||||
|
print(" 或者:")
|
||||||
|
print()
|
||||||
|
print(f" cd {DEPLOY_PATH}/deploy")
|
||||||
|
print(" docker-compose down && docker-compose up -d --build")
|
||||||
|
print()
|
||||||
|
print(f"🌐 配置完成后访问:")
|
||||||
|
print(f" 用户端: http://{DOMAIN}")
|
||||||
|
print(f" 管理后台: http://{DOMAIN}/admin")
|
||||||
|
print(f" API: http://{DOMAIN}/api/health")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
38
deploy/nginx/frontend.conf
Normal file
38
deploy/nginx/frontend.conf
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 前端静态资源
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理到后端
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_connect_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
}
|
||||||
68
deploy/nginx/interview.test.ai.ireborn.com.cn.conf
Normal file
68
deploy/nginx/interview.test.ai.ireborn.com.cn.conf
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# interview.test.ai.ireborn.com.cn Nginx 配置
|
||||||
|
# 宝塔面板配置:网站 -> 添加站点 -> 设置 -> 配置文件 -> 粘贴此内容
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name interview.test.ai.ireborn.com.cn;
|
||||||
|
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SSL 证书 (宝塔面板会自动生成,如果没有可以先注释掉 ssl 相关配置)
|
||||||
|
# ssl_certificate /www/server/panel/vhost/cert/interview.test.ai.ireborn.com.cn/fullchain.pem;
|
||||||
|
# ssl_certificate_key /www/server/panel/vhost/cert/interview.test.ai.ireborn.com.cn/privkey.pem;
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
|
||||||
|
# ssl_prefer_server_ciphers on;
|
||||||
|
# ssl_session_timeout 10m;
|
||||||
|
# ssl_session_cache shared:SSL:10m;
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
access_log /www/wwwlogs/interview.test.ai.ireborn.com.cn.log;
|
||||||
|
error_log /www/wwwlogs/interview.test.ai.ireborn.com.cn.error.log;
|
||||||
|
|
||||||
|
# 前端 - 代理到 Docker 前端容器 (3000 端口)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API - 代理到 Docker 后端容器 (8000 端口)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000/api/;
|
||||||
|
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;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_connect_timeout 120s;
|
||||||
|
|
||||||
|
# CORS 配置
|
||||||
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||||
|
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
|
||||||
|
|
||||||
|
# 处理 OPTIONS 预检请求
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
|
||||||
|
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
||||||
|
add_header Content-Length 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查端点
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:8000/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
deploy/rebuild.py
Normal file
158
deploy/rebuild.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
上传新的 Dockerfile 并重新构建
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# 禁用 SSL 警告
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# 宝塔配置
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
"""调用宝塔 API"""
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:1000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(shell_body, task_name, wait_time=30):
|
||||||
|
"""创建计划任务并执行"""
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"hour": "",
|
||||||
|
"minute": "",
|
||||||
|
"week": "",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
"sName": "",
|
||||||
|
"backupTo": "",
|
||||||
|
"save": "",
|
||||||
|
"urladdress": ""
|
||||||
|
})
|
||||||
|
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f" 创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cron_id = result["id"]
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=600)
|
||||||
|
print(f" 任务 {cron_id} 已启动,等待 {wait_time} 秒...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🐳 更新 Dockerfile 并重新构建")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 1. 读取新的 Dockerfile
|
||||||
|
dockerfile_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Dockerfile.backend")
|
||||||
|
with open(dockerfile_path, 'r') as f:
|
||||||
|
dockerfile_content = f.read()
|
||||||
|
|
||||||
|
# 2. 通过 shell 命令更新文件
|
||||||
|
encoded = base64.b64encode(dockerfile_content.encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
update_script = f"""#!/bin/bash
|
||||||
|
echo "更新 Dockerfile.backend..."
|
||||||
|
echo '{encoded}' | base64 -d > {DEPLOY_PATH}/deploy/Dockerfile.backend
|
||||||
|
cat {DEPLOY_PATH}/deploy/Dockerfile.backend
|
||||||
|
"""
|
||||||
|
|
||||||
|
print("📝 步骤 1: 更新 Dockerfile...")
|
||||||
|
result = run_task(update_script, f"update_dockerfile_{int(time.time())}", wait_time=10)
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print(result["msg"][:1000])
|
||||||
|
|
||||||
|
# 3. 重新构建
|
||||||
|
print("\n🐳 步骤 2: 重新构建 Docker 容器 (使用阿里云镜像)...")
|
||||||
|
rebuild_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
|
||||||
|
echo "停止旧容器..."
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo "删除旧镜像..."
|
||||||
|
docker rmi deploy-backend 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "重新构建 (这可能需要几分钟)..."
|
||||||
|
docker-compose build --no-cache backend 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "启动容器..."
|
||||||
|
docker-compose up -d 2>&1
|
||||||
|
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 容器状态 =========="
|
||||||
|
docker ps --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 后端日志 =========="
|
||||||
|
docker logs --tail 30 ai-interview-backend 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 测试服务 =========="
|
||||||
|
curl -s http://127.0.0.1:8000/health 2>&1 || echo "后端未响应"
|
||||||
|
echo ""
|
||||||
|
curl -s -o /dev/null -w "前端: HTTP %{{http_code}}" http://127.0.0.1:3000 2>&1
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_task(rebuild_script, f"rebuild_{int(time.time())}", wait_time=180)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n📋 构建结果:")
|
||||||
|
print("-" * 60)
|
||||||
|
print(result["msg"])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("✅ 完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print("🌐 访问地址:")
|
||||||
|
print(" http://interview.test.ai.ireborn.com.cn")
|
||||||
|
print(" http://interview.test.ai.ireborn.com.cn/admin")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
147
deploy/restart_docker.py
Normal file
147
deploy/restart_docker.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
通过宝塔计划任务重启 Docker 容器
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 禁用 SSL 警告
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# 宝塔配置
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
"""调用宝塔 API"""
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:500]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def add_crontab(name, shell_body):
|
||||||
|
"""添加计划任务"""
|
||||||
|
print(f"📋 添加计划任务: {name}")
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"hour": "",
|
||||||
|
"minute": "",
|
||||||
|
"week": "",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
"sName": "",
|
||||||
|
"backupTo": "",
|
||||||
|
"save": "",
|
||||||
|
"urladdress": ""
|
||||||
|
})
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def exec_crontab(cron_id):
|
||||||
|
"""立即执行计划任务"""
|
||||||
|
print(f"▶️ 执行计划任务 ID: {cron_id}")
|
||||||
|
result = bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=300)
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def delete_crontab(cron_id):
|
||||||
|
"""删除计划任务"""
|
||||||
|
print(f"🗑️ 删除计划任务 ID: {cron_id}")
|
||||||
|
result = bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_crontab_list():
|
||||||
|
"""获取计划任务列表"""
|
||||||
|
result = bt_api("crontab?action=GetCrontab", {"page": 1, "search": ""})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🐳 重启 Docker 容器")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Docker 重启命令
|
||||||
|
shell_body = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
docker-compose up -d --build
|
||||||
|
sleep 5
|
||||||
|
echo "Docker 容器状态:"
|
||||||
|
docker ps --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
echo ""
|
||||||
|
echo "测试后端服务:"
|
||||||
|
curl -s http://127.0.0.1:8000/health || echo "后端未响应"
|
||||||
|
echo ""
|
||||||
|
echo "测试前端服务:"
|
||||||
|
curl -s -o /dev/null -w "HTTP Status: %{{http_code}}" http://127.0.0.1:3000 || echo "前端未响应"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 1. 添加计划任务
|
||||||
|
task_name = f"restart_docker_{int(time.time())}"
|
||||||
|
result = add_crontab(task_name, shell_body)
|
||||||
|
|
||||||
|
if result.get("status") and result.get("id"):
|
||||||
|
cron_id = result["id"]
|
||||||
|
print(f"\n✅ 计划任务创建成功,ID: {cron_id}")
|
||||||
|
|
||||||
|
# 2. 立即执行
|
||||||
|
print("\n🚀 立即执行计划任务...")
|
||||||
|
exec_result = exec_crontab(cron_id)
|
||||||
|
|
||||||
|
# 3. 等待执行完成
|
||||||
|
print("\n⏳ 等待执行完成...")
|
||||||
|
time.sleep(30)
|
||||||
|
|
||||||
|
# 4. 删除计划任务
|
||||||
|
delete_crontab(cron_id)
|
||||||
|
|
||||||
|
print("\n✅ Docker 重启命令已执行!")
|
||||||
|
else:
|
||||||
|
print(f"\n❌ 计划任务创建失败: {result}")
|
||||||
|
print("\n⚠️ 请手动在服务器执行:")
|
||||||
|
print(f" cd {DEPLOY_PATH}/deploy")
|
||||||
|
print(" docker-compose down && docker-compose up -d --build")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("🌐 访问地址:")
|
||||||
|
print(" http://interview.test.ai.ireborn.com.cn")
|
||||||
|
print(" http://interview.test.ai.ireborn.com.cn/admin")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
96
deploy/setup-server.sh
Normal file
96
deploy/setup-server.sh
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# AI Interview 服务器部署脚本
|
||||||
|
# ============================================
|
||||||
|
# 在服务器上执行此脚本来快速部署
|
||||||
|
#
|
||||||
|
# 使用方法:
|
||||||
|
# chmod +x setup-server.sh
|
||||||
|
# ./setup-server.sh
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "AI Interview 部署脚本"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 项目目录
|
||||||
|
PROJECT_DIR="/www/wwwroot/ai-interview"
|
||||||
|
DEPLOY_DIR="$PROJECT_DIR/deploy"
|
||||||
|
|
||||||
|
# 进入部署目录
|
||||||
|
cd $DEPLOY_DIR
|
||||||
|
|
||||||
|
# 1. 复制环境变量文件
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "[1/4] 创建 .env 文件..."
|
||||||
|
cp env.production .env
|
||||||
|
echo "✅ .env 文件已创建"
|
||||||
|
else
|
||||||
|
echo "[1/4] .env 文件已存在,跳过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 创建上传目录
|
||||||
|
echo "[2/4] 创建上传目录..."
|
||||||
|
mkdir -p $DEPLOY_DIR/uploads
|
||||||
|
chmod 755 $DEPLOY_DIR/uploads
|
||||||
|
echo "✅ 上传目录已创建"
|
||||||
|
|
||||||
|
# 3. 停止旧容器
|
||||||
|
echo "[3/4] 停止旧容器..."
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
echo "✅ 旧容器已停止"
|
||||||
|
|
||||||
|
# 4. 构建并启动新容器
|
||||||
|
echo "[4/4] 构建并启动容器..."
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
echo ""
|
||||||
|
echo "等待服务启动..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 检查容器状态
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "容器状态:"
|
||||||
|
echo "=========================================="
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# 检查服务健康
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "健康检查:"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 检查后端
|
||||||
|
if curl -s http://127.0.0.1:8000/health > /dev/null 2>&1; then
|
||||||
|
echo "✅ 后端服务正常 (http://127.0.0.1:8000)"
|
||||||
|
else
|
||||||
|
echo "❌ 后端服务未响应"
|
||||||
|
echo "查看后端日志: docker logs ai-interview-backend"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查前端
|
||||||
|
if curl -s http://127.0.0.1:3000 > /dev/null 2>&1; then
|
||||||
|
echo "✅ 前端服务正常 (http://127.0.0.1:3000)"
|
||||||
|
else
|
||||||
|
echo "❌ 前端服务未响应"
|
||||||
|
echo "查看前端日志: docker logs ai-interview-frontend"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "部署完成!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "访问地址:"
|
||||||
|
echo " - 用户端: http://interview.test.ai.ireborn.com.cn"
|
||||||
|
echo " - 后台: http://interview.test.ai.ireborn.com.cn/admin"
|
||||||
|
echo " - API: http://interview.test.ai.ireborn.com.cn/api/health"
|
||||||
|
echo ""
|
||||||
|
echo "常用命令:"
|
||||||
|
echo " - 查看日志: docker-compose logs -f"
|
||||||
|
echo " - 重启服务: docker-compose restart"
|
||||||
|
echo " - 停止服务: docker-compose down"
|
||||||
|
echo ""
|
||||||
114
deploy/start_backend_only.py
Normal file
114
deploy/start_backend_only.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
只启动后端容器
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:2000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(shell_body, task_name, wait_time=30):
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
})
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f"创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
cron_id = result["id"]
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=300)
|
||||||
|
print(f"任务 {cron_id} 已启动,等待 {wait_time} 秒...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🚀 单独启动后端容器")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 直接用 docker run 启动后端容器
|
||||||
|
start_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
|
||||||
|
# 停止旧容器
|
||||||
|
docker stop ai-interview-backend 2>/dev/null || true
|
||||||
|
docker rm ai-interview-backend 2>/dev/null || true
|
||||||
|
|
||||||
|
# 读取 .env 文件
|
||||||
|
source .env 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "启动后端容器..."
|
||||||
|
docker run -d \\
|
||||||
|
--name ai-interview-backend \\
|
||||||
|
-p 8000:8000 \\
|
||||||
|
-e COZE_PAT_TOKEN="$COZE_PAT_TOKEN" \\
|
||||||
|
-e COZE_BOT_ID="$COZE_BOT_ID" \\
|
||||||
|
-e COZE_WORKFLOW_A_ID="$COZE_WORKFLOW_A_ID" \\
|
||||||
|
-e COZE_WORKFLOW_C_ID="$COZE_WORKFLOW_C_ID" \\
|
||||||
|
-e FILE_SERVER_URL="$FILE_SERVER_URL" \\
|
||||||
|
-e FILE_SERVER_TOKEN="$FILE_SERVER_TOKEN" \\
|
||||||
|
-v {DEPLOY_PATH}/deploy/uploads:/app/uploads \\
|
||||||
|
--restart unless-stopped \\
|
||||||
|
deploy-backend
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 容器状态 =========="
|
||||||
|
docker ps --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 后端日志 =========="
|
||||||
|
docker logs --tail 30 ai-interview-backend 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 测试后端 =========="
|
||||||
|
curl -s http://127.0.0.1:8000/health 2>&1 || echo "后端未响应"
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_task(start_script, f"start_backend_{int(time.time())}", wait_time=20)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n" + result["msg"])
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("测试后端 API:")
|
||||||
|
print(" http://interview.test.ai.ireborn.com.cn/api/health")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
100
deploy/start_containers.py
Normal file
100
deploy/start_containers.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
启动 Docker 容器
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:2000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(shell_body, task_name, wait_time=30):
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
})
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f"创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
cron_id = result["id"]
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=300)
|
||||||
|
print(f"任务 {cron_id} 已启动,等待 {wait_time} 秒...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🚀 启动 Docker 容器")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
start_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
|
||||||
|
echo "当前目录文件:"
|
||||||
|
ls -la
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "启动容器..."
|
||||||
|
docker-compose up -d 2>&1
|
||||||
|
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 容器状态 =========="
|
||||||
|
docker ps -a --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 后端日志 =========="
|
||||||
|
docker logs --tail 30 ai-interview-backend 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 测试服务 =========="
|
||||||
|
curl -s http://127.0.0.1:8000/health 2>&1 || echo "后端未响应"
|
||||||
|
echo ""
|
||||||
|
curl -s -o /dev/null -w "前端: HTTP %{{http_code}}" http://127.0.0.1:3000 2>&1
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_task(start_script, f"start_{int(time.time())}", wait_time=30)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n" + result["msg"])
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🌐 http://interview.test.ai.ireborn.com.cn")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
237
deploy/upload_all.py
Normal file
237
deploy/upload_all.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
上传所有代码到服务器并部署
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
LOCAL_PROJECT = "/Users/a111/Documents/AgentWD/projects/011-ai-interview-2601"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:1000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(shell_body, task_name, wait_time=30):
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
})
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f" 创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
cron_id = result["id"]
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=600)
|
||||||
|
print(f" 任务 {cron_id} 启动,等待 {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file_via_task(remote_path, content):
|
||||||
|
"""通过计划任务上传文件"""
|
||||||
|
encoded = base64.b64encode(content.encode('utf-8')).decode('utf-8')
|
||||||
|
dir_path = os.path.dirname(remote_path)
|
||||||
|
script = f"""#!/bin/bash
|
||||||
|
mkdir -p {dir_path}
|
||||||
|
echo '{encoded}' | base64 -d > {remote_path}
|
||||||
|
"""
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": f"upload_{int(time.time())}",
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": script,
|
||||||
|
})
|
||||||
|
if result.get("status") and result.get("id"):
|
||||||
|
cron_id = result["id"]
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=60)
|
||||||
|
time.sleep(2)
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("🚀 完整部署 - 上传所有代码")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 前端文件列表
|
||||||
|
frontend_files = [
|
||||||
|
"package.json",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"vite.config.ts",
|
||||||
|
"tsconfig.json",
|
||||||
|
"tsconfig.node.json",
|
||||||
|
"tailwind.config.js",
|
||||||
|
"postcss.config.js",
|
||||||
|
"index.html",
|
||||||
|
"src/main.ts",
|
||||||
|
"src/App.vue",
|
||||||
|
"src/styles/index.css",
|
||||||
|
"src/router/index.ts",
|
||||||
|
"src/api/index.ts",
|
||||||
|
"src/api/request.ts",
|
||||||
|
"src/api/candidate.ts",
|
||||||
|
"src/composables/index.ts",
|
||||||
|
"src/composables/useRTC.ts",
|
||||||
|
"src/composables/useCozeRealtime.ts",
|
||||||
|
"src/types/index.ts",
|
||||||
|
"src/types/env.d.ts",
|
||||||
|
"src/layouts/AdminLayout.vue",
|
||||||
|
"src/layouts/InterviewLayout.vue",
|
||||||
|
"src/pages/interview/index.vue",
|
||||||
|
"src/pages/interview/info.vue",
|
||||||
|
"src/pages/interview/call.vue",
|
||||||
|
"src/pages/interview/complete.vue",
|
||||||
|
"src/pages/admin/index.vue",
|
||||||
|
"src/pages/admin/login.vue",
|
||||||
|
"src/pages/admin/dashboard.vue",
|
||||||
|
"src/pages/admin/interviews.vue",
|
||||||
|
"src/pages/admin/interview-detail.vue",
|
||||||
|
"src/pages/admin/configs.vue",
|
||||||
|
"src/pages/admin/layout.vue",
|
||||||
|
"src/pages/admin/[id].vue",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 后端文件列表
|
||||||
|
backend_files = [
|
||||||
|
"main.py",
|
||||||
|
"requirements.txt",
|
||||||
|
"app/__init__.py",
|
||||||
|
"app/config.py",
|
||||||
|
"app/schemas.py",
|
||||||
|
"app/routers/__init__.py",
|
||||||
|
"app/routers/admin.py",
|
||||||
|
"app/routers/candidate.py",
|
||||||
|
"app/routers/chat.py",
|
||||||
|
"app/routers/files.py",
|
||||||
|
"app/routers/init.py",
|
||||||
|
"app/routers/room.py",
|
||||||
|
"app/routers/upload.py",
|
||||||
|
"app/services/__init__.py",
|
||||||
|
"app/services/coze_service.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 部署配置文件
|
||||||
|
deploy_files = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"Dockerfile.backend",
|
||||||
|
"Dockerfile.frontend",
|
||||||
|
"nginx/frontend.conf",
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n📤 步骤 1: 上传前端代码...")
|
||||||
|
for f in frontend_files:
|
||||||
|
local_path = os.path.join(LOCAL_PROJECT, "frontend", f)
|
||||||
|
if os.path.exists(local_path):
|
||||||
|
with open(local_path, 'r', encoding='utf-8') as fp:
|
||||||
|
content = fp.read()
|
||||||
|
remote_path = f"{DEPLOY_PATH}/frontend/{f}"
|
||||||
|
print(f" 📝 {f}")
|
||||||
|
upload_file_via_task(remote_path, content)
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 跳过: {f}")
|
||||||
|
|
||||||
|
print("\n📤 步骤 2: 上传后端代码...")
|
||||||
|
for f in backend_files:
|
||||||
|
local_path = os.path.join(LOCAL_PROJECT, "backend", f)
|
||||||
|
if os.path.exists(local_path):
|
||||||
|
with open(local_path, 'r', encoding='utf-8') as fp:
|
||||||
|
content = fp.read()
|
||||||
|
remote_path = f"{DEPLOY_PATH}/backend/{f}"
|
||||||
|
print(f" 📝 {f}")
|
||||||
|
upload_file_via_task(remote_path, content)
|
||||||
|
|
||||||
|
print("\n📤 步骤 3: 上传部署配置...")
|
||||||
|
for f in deploy_files:
|
||||||
|
local_path = os.path.join(LOCAL_PROJECT, "deploy", f)
|
||||||
|
if os.path.exists(local_path):
|
||||||
|
with open(local_path, 'r', encoding='utf-8') as fp:
|
||||||
|
content = fp.read()
|
||||||
|
remote_path = f"{DEPLOY_PATH}/deploy/{f}"
|
||||||
|
print(f" 📝 {f}")
|
||||||
|
upload_file_via_task(remote_path, content)
|
||||||
|
|
||||||
|
print("\n🐳 步骤 4: 构建并启动 Docker...")
|
||||||
|
build_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
|
||||||
|
echo "目录结构:"
|
||||||
|
ls -la {DEPLOY_PATH}/
|
||||||
|
ls -la {DEPLOY_PATH}/frontend/ | head -10
|
||||||
|
ls -la {DEPLOY_PATH}/backend/ | head -10
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "停止旧容器..."
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "删除旧镜像..."
|
||||||
|
docker rmi deploy-backend deploy-frontend 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "构建并启动..."
|
||||||
|
docker-compose up -d --build 2>&1
|
||||||
|
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 容器状态 =========="
|
||||||
|
docker ps -a --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 后端日志 =========="
|
||||||
|
docker logs --tail 20 ai-interview-backend 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========== 测试 =========="
|
||||||
|
curl -s http://127.0.0.1:8000/health 2>&1 || echo "后端未响应"
|
||||||
|
echo ""
|
||||||
|
curl -s -o /dev/null -w "前端: %{{http_code}}" http://127.0.0.1:3000 2>&1
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_task(build_script, f"build_{int(time.time())}", wait_time=180)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n📋 构建结果:")
|
||||||
|
print("-" * 60)
|
||||||
|
print(result["msg"][-3000:])
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🌐 http://interview.test.ai.ireborn.com.cn")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
212
deploy/upload_backend.py
Normal file
212
deploy/upload_backend.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
上传后端代码到服务器并重新构建
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 禁用 SSL 警告
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# 宝塔配置
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
LOCAL_BACKEND = "/Users/a111/Documents/AgentWD/projects/011-ai-interview-2601/backend"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
"""调用宝塔 API"""
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:500]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def write_remote_file(path, content):
|
||||||
|
"""写入远程文件"""
|
||||||
|
print(f"📝 写入: {path}")
|
||||||
|
result = bt_api("files?action=SaveFileBody", {
|
||||||
|
"path": path,
|
||||||
|
"data": content,
|
||||||
|
"encoding": "utf-8"
|
||||||
|
})
|
||||||
|
if result.get("status"):
|
||||||
|
print(" ✅ 成功")
|
||||||
|
return True
|
||||||
|
print(f" ❌ 失败: {result.get('msg', result)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_directory(path):
|
||||||
|
"""创建目录"""
|
||||||
|
result = bt_api("files?action=CreateDir", {"path": path})
|
||||||
|
return result.get("status") or "已存在" in str(result.get("msg", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(shell_body, task_name, wait_time=30):
|
||||||
|
"""创建计划任务并执行"""
|
||||||
|
result = bt_api("crontab?action=AddCrontab", {
|
||||||
|
"name": task_name,
|
||||||
|
"type": "minute-n",
|
||||||
|
"where1": "1",
|
||||||
|
"hour": "",
|
||||||
|
"minute": "",
|
||||||
|
"week": "",
|
||||||
|
"sType": "toShell",
|
||||||
|
"sBody": shell_body,
|
||||||
|
"sName": "",
|
||||||
|
"backupTo": "",
|
||||||
|
"save": "",
|
||||||
|
"urladdress": ""
|
||||||
|
})
|
||||||
|
|
||||||
|
if not result.get("status") or not result.get("id"):
|
||||||
|
print(f"创建任务失败: {result}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cron_id = result["id"]
|
||||||
|
bt_api("crontab?action=StartTask", {"id": cron_id}, timeout=300)
|
||||||
|
print(f" 任务 {cron_id} 已启动,等待 {wait_time} 秒...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
log_result = bt_api("crontab?action=GetLogs", {"id": cron_id})
|
||||||
|
bt_api("crontab?action=DelCrontab", {"id": cron_id})
|
||||||
|
|
||||||
|
return log_result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("📤 上传后端代码到服务器")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 1. 创建目录结构
|
||||||
|
print("📁 步骤 1: 创建目录结构")
|
||||||
|
dirs = [
|
||||||
|
f"{DEPLOY_PATH}/backend",
|
||||||
|
f"{DEPLOY_PATH}/backend/app",
|
||||||
|
f"{DEPLOY_PATH}/backend/app/routers",
|
||||||
|
f"{DEPLOY_PATH}/backend/app/services",
|
||||||
|
]
|
||||||
|
for d in dirs:
|
||||||
|
create_directory(d)
|
||||||
|
print(f" ✅ {d}")
|
||||||
|
|
||||||
|
# 2. 上传后端代码
|
||||||
|
print("\n📤 步骤 2: 上传后端代码")
|
||||||
|
|
||||||
|
# 定义要上传的文件
|
||||||
|
files_to_upload = [
|
||||||
|
("main.py", ""),
|
||||||
|
("requirements.txt", ""),
|
||||||
|
("app/__init__.py", "app"),
|
||||||
|
("app/config.py", "app"),
|
||||||
|
("app/schemas.py", "app"),
|
||||||
|
("app/routers/__init__.py", "app/routers"),
|
||||||
|
("app/routers/admin.py", "app/routers"),
|
||||||
|
("app/routers/candidate.py", "app/routers"),
|
||||||
|
("app/routers/chat.py", "app/routers"),
|
||||||
|
("app/routers/files.py", "app/routers"),
|
||||||
|
("app/routers/init.py", "app/routers"),
|
||||||
|
("app/routers/room.py", "app/routers"),
|
||||||
|
("app/routers/upload.py", "app/routers"),
|
||||||
|
("app/services/__init__.py", "app/services"),
|
||||||
|
("app/services/coze_service.py", "app/services"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for local_file, subdir in files_to_upload:
|
||||||
|
local_path = os.path.join(LOCAL_BACKEND, local_file)
|
||||||
|
if os.path.exists(local_path):
|
||||||
|
with open(local_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if subdir:
|
||||||
|
remote_path = f"{DEPLOY_PATH}/backend/{subdir}/{os.path.basename(local_file)}"
|
||||||
|
else:
|
||||||
|
remote_path = f"{DEPLOY_PATH}/backend/{local_file}"
|
||||||
|
|
||||||
|
write_remote_file(remote_path, content)
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 文件不存在: {local_path}")
|
||||||
|
|
||||||
|
# 3. 上传 deploy 配置文件
|
||||||
|
print("\n📤 步骤 3: 上传部署配置")
|
||||||
|
deploy_files = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"Dockerfile.backend",
|
||||||
|
"Dockerfile.frontend",
|
||||||
|
]
|
||||||
|
|
||||||
|
for filename in deploy_files:
|
||||||
|
local_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
|
||||||
|
if os.path.exists(local_path):
|
||||||
|
with open(local_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
write_remote_file(f"{DEPLOY_PATH}/deploy/{filename}", content)
|
||||||
|
|
||||||
|
# 上传 nginx 配置
|
||||||
|
nginx_conf_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "nginx", "frontend.conf")
|
||||||
|
if os.path.exists(nginx_conf_path):
|
||||||
|
create_directory(f"{DEPLOY_PATH}/deploy/nginx")
|
||||||
|
with open(nginx_conf_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
write_remote_file(f"{DEPLOY_PATH}/deploy/nginx/frontend.conf", content)
|
||||||
|
|
||||||
|
# 4. 重新构建 Docker 容器
|
||||||
|
print("\n🐳 步骤 4: 重新构建 Docker 容器")
|
||||||
|
rebuild_script = f"""#!/bin/bash
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
echo "停止旧容器..."
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
echo "清理旧镜像..."
|
||||||
|
docker rmi deploy-backend 2>/dev/null || true
|
||||||
|
echo "重新构建..."
|
||||||
|
docker-compose up -d --build
|
||||||
|
sleep 10
|
||||||
|
echo "容器状态:"
|
||||||
|
docker ps --format 'table {{{{.Names}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'
|
||||||
|
echo ""
|
||||||
|
echo "后端日志:"
|
||||||
|
docker logs --tail 20 ai-interview-backend 2>&1
|
||||||
|
"""
|
||||||
|
|
||||||
|
print(" 🚀 执行重建命令...")
|
||||||
|
result = run_task(rebuild_script, f"rebuild_docker_{int(time.time())}", wait_time=60)
|
||||||
|
|
||||||
|
if result and result.get("msg"):
|
||||||
|
print("\n📋 执行结果:")
|
||||||
|
print("-" * 40)
|
||||||
|
print(result["msg"][:2000])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("✅ 上传完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
111
deploy/write_deploy_script.py
Normal file
111
deploy/write_deploy_script.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
通过文件 API 写入部署脚本
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
BT_PANEL = "http://47.107.172.23:8888"
|
||||||
|
BT_API_KEY = "PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq"
|
||||||
|
DEPLOY_PATH = "/www/wwwroot/ai-interview"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_api(action, data=None, timeout=60):
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
request_time = int(time.time())
|
||||||
|
request_token = hashlib.md5(
|
||||||
|
f"{request_time}{hashlib.md5(BT_API_KEY.encode()).hexdigest()}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
data['request_time'] = request_time
|
||||||
|
data['request_token'] = request_token
|
||||||
|
url = f"{BT_PANEL}/{action}"
|
||||||
|
try:
|
||||||
|
response = requests.post(url, data=data, timeout=timeout, verify=False)
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"status": True, "msg": response.text[:2000]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("📝 写入部署脚本到服务器")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
deploy_script = f"""#!/bin/bash
|
||||||
|
# AI Interview 部署脚本
|
||||||
|
|
||||||
|
cd {DEPLOY_PATH}/deploy
|
||||||
|
|
||||||
|
echo "清理 Docker..."
|
||||||
|
docker system prune -af 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "构建并启动..."
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
echo "容器状态:"
|
||||||
|
docker ps -a
|
||||||
|
|
||||||
|
echo "后端日志:"
|
||||||
|
docker logs --tail 30 ai-interview-backend 2>&1
|
||||||
|
|
||||||
|
echo "测试:"
|
||||||
|
curl -s http://127.0.0.1:8000/health || echo "后端未响应"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 方法1: 尝试写入已存在的路径
|
||||||
|
paths_to_try = [
|
||||||
|
f"{DEPLOY_PATH}/deploy.sh",
|
||||||
|
f"{DEPLOY_PATH}/deploy/deploy.sh",
|
||||||
|
"/tmp/deploy.sh",
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in paths_to_try:
|
||||||
|
print(f"\n尝试写入: {path}")
|
||||||
|
|
||||||
|
# 先尝试创建空文件
|
||||||
|
result = bt_api("files?action=CreateFile", {"path": path})
|
||||||
|
print(f" 创建文件: {result}")
|
||||||
|
|
||||||
|
# 然后写入内容
|
||||||
|
result = bt_api("files?action=SaveFileBody", {
|
||||||
|
"path": path,
|
||||||
|
"data": deploy_script,
|
||||||
|
"encoding": "utf-8"
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.get("status"):
|
||||||
|
print(f" ✅ 写入成功!")
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("请在服务器执行:")
|
||||||
|
print(f" bash {path}")
|
||||||
|
print("=" * 60)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(f" ❌ {result.get('msg', result)}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("❌ 所有方法都失败了")
|
||||||
|
print()
|
||||||
|
print("请手动在服务器终端执行:")
|
||||||
|
print()
|
||||||
|
print(f"cd {DEPLOY_PATH}/deploy")
|
||||||
|
print("docker system prune -af")
|
||||||
|
print("docker-compose up -d --build")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
247
docs/PRD.md
Normal file
247
docs/PRD.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
# 产品需求文档
|
||||||
|
|
||||||
|
> 版本:v1.0
|
||||||
|
> 最后更新:2026-01-20
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更内容 | 作者 |
|
||||||
|
|------|------|---------|------|
|
||||||
|
| v1.0 | 2026-01-20 | 初始版本 | AI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目概述
|
||||||
|
|
||||||
|
### 1.1 项目背景
|
||||||
|
|
||||||
|
为轻医美行业打造 AI 面试官系统,实现咨询师岗位的智能初试。通过 AI 语音面试技术,提升招聘效率,标准化面试流程,降低 HR 初筛工作量。
|
||||||
|
|
||||||
|
### 1.2 项目目标
|
||||||
|
|
||||||
|
1. 实现 7x24 小时无人值守的智能初试
|
||||||
|
2. 标准化面试流程,确保评估一致性
|
||||||
|
3. 自动生成面试报告,辅助 HR 决策
|
||||||
|
4. 提升候选人体验,缩短招聘周期
|
||||||
|
|
||||||
|
### 1.3 核心价值
|
||||||
|
|
||||||
|
| 角色 | 价值 |
|
||||||
|
|------|------|
|
||||||
|
| HR | 减少初筛工作量,获得标准化评估报告 |
|
||||||
|
| 候选人 | 随时参与面试,体验创新面试形式 |
|
||||||
|
| 企业 | 降低招聘成本,提升招聘效率 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、用户角色
|
||||||
|
|
||||||
|
### 2.1 候选人
|
||||||
|
|
||||||
|
- **画像**:应聘轻医美咨询师岗位的求职者
|
||||||
|
- **使用场景**:收到面试邀请后,通过网页参与 AI 语音面试
|
||||||
|
- **核心诉求**:快速完成面试,获得反馈
|
||||||
|
|
||||||
|
### 2.2 HR/招聘专员
|
||||||
|
|
||||||
|
- **画像**:负责招聘的人力资源专员
|
||||||
|
- **使用场景**:在管理后台查看候选人面试结果和分析报告
|
||||||
|
- **核心诉求**:快速筛选合适候选人,减少重复劳动
|
||||||
|
|
||||||
|
### 2.3 管理员
|
||||||
|
|
||||||
|
- **画像**:系统管理员
|
||||||
|
- **使用场景**:管理系统配置、查看所有候选人数据
|
||||||
|
- **核心诉求**:系统稳定运行,数据安全
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、功能需求
|
||||||
|
|
||||||
|
### 3.1 用户端功能
|
||||||
|
|
||||||
|
#### F-001 欢迎页(Welcome)
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 功能描述 | 展示公司/品牌介绍,引导候选人开始面试 |
|
||||||
|
| 页面元素 | 公司 Logo、欢迎语、"开始面试"按钮 |
|
||||||
|
| 交互逻辑 | 点击按钮进入信息收集页 |
|
||||||
|
|
||||||
|
#### F-002 信息收集页(InfoCollection)
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 功能描述 | 收集候选人姓名和简历 |
|
||||||
|
| 页面元素 | 姓名输入框、简历上传组件、提交按钮 |
|
||||||
|
| 交互逻辑 | 1. 输入姓名(必填)<br>2. 上传简历(PDF/DOC/DOCX)<br>3. 提交后显示加载状态<br>4. 处理完成进入来电页 |
|
||||||
|
| 校验规则 | 姓名:2-20字符;简历:≤10MB |
|
||||||
|
|
||||||
|
#### F-003 模拟来电页(IncomingCall)
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 功能描述 | 模拟电话来电效果,增强仪式感 |
|
||||||
|
| 页面元素 | 来电动画、"AI面试官来电中..."文案、接听按钮(绿)、挂断按钮(红) |
|
||||||
|
| 交互逻辑 | 1. 显示振铃动画<br>2. 点击接听进入通话页<br>3. 点击挂断返回欢迎页 |
|
||||||
|
|
||||||
|
#### F-004 语音通话页(InCall)
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 功能描述 | 与 AI 面试官进行实时语音对话 |
|
||||||
|
| 页面元素 | 通话计时器、音波动画、静音按钮、挂断按钮 |
|
||||||
|
| 交互逻辑 | 1. 自动开启麦克风<br>2. AI 说话时显示音波动画<br>3. 可随时静音/取消静音<br>4. 面试结束自动跳转或点击挂断结束 |
|
||||||
|
|
||||||
|
#### F-005 面试结束页(Completed)
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 功能描述 | 展示面试完成信息 |
|
||||||
|
| 页面元素 | 感谢语、后续流程说明 |
|
||||||
|
| 交互逻辑 | 静态展示,可选择关闭页面 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 管理后台功能
|
||||||
|
|
||||||
|
#### F-101 候选人列表
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 功能描述 | 展示所有候选人及其面试状态 |
|
||||||
|
| 页面元素 | 数据表格、搜索框、筛选器、分页 |
|
||||||
|
| 表格字段 | 姓名、面试时间、状态、综合评分、操作 |
|
||||||
|
| 筛选条件 | 状态(待面试/进行中/已完成)、时间范围 |
|
||||||
|
|
||||||
|
#### F-102 候选人详情
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 功能描述 | 展示候选人完整面试报告 |
|
||||||
|
| 页面元素 | 基本信息、简历内容、评分雷达图、各维度分析、面试记录、导出按钮 |
|
||||||
|
| 评分维度 | 销售技能、销售观、素质项、求职动机 |
|
||||||
|
| 导出功能 | 支持导出 PDF 报告 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、业务流程
|
||||||
|
|
||||||
|
### 4.1 用户端面试流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ 欢迎页 │
|
||||||
|
│ Welcome │
|
||||||
|
└──────┬──────┘
|
||||||
|
│ 点击"开始面试"
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 信息收集页 │
|
||||||
|
│ InfoCollection│
|
||||||
|
└──────┬──────┘
|
||||||
|
│ 提交姓名+简历
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 处理中 │
|
||||||
|
│ Processing │
|
||||||
|
└──────┬──────┘
|
||||||
|
│ 简历上传完成
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 模拟来电页 │
|
||||||
|
│ IncomingCall│
|
||||||
|
└──────┬──────┘
|
||||||
|
│ 点击"接听"
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 语音通话页 │
|
||||||
|
│ InCall │──────────────┐
|
||||||
|
└──────┬──────┘ │
|
||||||
|
│ 面试结束 │ RTC 实时语音
|
||||||
|
▼ │ 对接 Coze Bot
|
||||||
|
┌─────────────┐ │
|
||||||
|
│ 结束页 │◄─────────────┘
|
||||||
|
│ Completed │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 面试评估维度
|
||||||
|
|
||||||
|
| 维度 | 评估内容 | 权重 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 销售技能 | 客户沟通、需求挖掘、异议处理 | 30% |
|
||||||
|
| 销售观 | 对销售工作的理解和价值观 | 25% |
|
||||||
|
| 素质项 | 学习能力、抗压能力、团队协作 | 25% |
|
||||||
|
| 求职动机 | 岗位匹配度、稳定性、职业规划 | 20% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、非功能需求
|
||||||
|
|
||||||
|
### 5.1 性能要求
|
||||||
|
|
||||||
|
| 指标 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 页面加载时间 | < 3s |
|
||||||
|
| 语音延迟 | < 500ms |
|
||||||
|
| 并发面试数 | 支持 50 人同时面试 |
|
||||||
|
|
||||||
|
### 5.2 兼容性要求
|
||||||
|
|
||||||
|
| 平台 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 浏览器 | Chrome 90+、Edge 90+、Safari 14+ |
|
||||||
|
| 设备 | PC 为主,移动端适配 |
|
||||||
|
|
||||||
|
### 5.3 安全要求
|
||||||
|
|
||||||
|
| 项目 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 数据传输 | HTTPS 加密 |
|
||||||
|
| 敏感信息 | API Key 不暴露给前端 |
|
||||||
|
| 权限控制 | 管理后台需登录验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、技术约束
|
||||||
|
|
||||||
|
### 6.1 现有资源
|
||||||
|
|
||||||
|
| 资源 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| Coze 工作流 | 已有完整面试逻辑(工作流 ID:7595077233002840079) |
|
||||||
|
| Coze 数据库 | 已有数据结构(数据库 ID:7595077053909712922) |
|
||||||
|
|
||||||
|
### 6.2 技术依赖
|
||||||
|
|
||||||
|
| 依赖 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 火山引擎 RTC | 实时语音通话 |
|
||||||
|
| Coze API | 文件上传、Bot 调用、数据库查询 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、里程碑
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 预计时间 |
|
||||||
|
|------|------|---------|
|
||||||
|
| M1 | 需求确认、技术选型 | 1 天 |
|
||||||
|
| M2 | 后端 API 开发 | 2 天 |
|
||||||
|
| M3 | 前端用户端开发 | 3 天 |
|
||||||
|
| M4 | 前端管理后台开发 | 2 天 |
|
||||||
|
| M5 | 联调测试 | 2 天 |
|
||||||
|
| M6 | 上线部署 | 1 天 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、待确认事项
|
||||||
|
|
||||||
|
- [ ] 管理后台是否需要登录认证?
|
||||||
|
- [ ] 是否需要候选人邀请链接功能?
|
||||||
|
- [ ] 面试时长限制?(建议 15-20 分钟)
|
||||||
|
- [ ] 是否需要面试录音回放?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 最后更新:2026-01-20
|
||||||
64
docs/README.md
Normal file
64
docs/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 文档索引
|
||||||
|
|
||||||
|
> 本项目文档导航
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心文档
|
||||||
|
|
||||||
|
| 文档 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| [PRD.md](PRD.md) | 产品需求文档 | ✅ v1.0 |
|
||||||
|
| [同步清单.md](同步清单.md) | 任务状态与待办 | ✅ |
|
||||||
|
| [决策记录.md](决策记录.md) | 所有决策记录 | ✅ |
|
||||||
|
| [项目状态快照.md](项目状态快照.md) | 项目全貌概览 | ✅ |
|
||||||
|
| [功能模块索引.md](功能模块索引.md) | 功能→决策映射 | 📝 待创建 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术文档
|
||||||
|
|
||||||
|
| 文档 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| [技术选型.md](技术选型.md) | 项目技术选型(含覆盖声明) | ✅ |
|
||||||
|
| [api/endpoints.md](api/endpoints.md) | API 接口文档 | ✅ |
|
||||||
|
| [database/表结构.md](database/表结构.md) | Coze 数据库结构 | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 交接相关
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| [交接文档/](交接文档/) | 对话框交接文档 |
|
||||||
|
| [对话框摘要/](对话框摘要/) | 历史对话框摘要 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审视记录
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| [PRD审视记录/](PRD审视记录/) | PRD多角色审视记录 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 发布相关
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| [发布记录.md](发布记录.md) | 版本发布历史 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速导航
|
||||||
|
|
||||||
|
- **了解项目** → [PRD.md](PRD.md)
|
||||||
|
- **技术方案** → [技术选型.md](技术选型.md)
|
||||||
|
- **当前任务** → [同步清单.md](同步清单.md)
|
||||||
|
- **历史决策** → [决策记录.md](决策记录.md)
|
||||||
|
- **接口文档** → [api/endpoints.md](api/endpoints.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 最后更新:2026-01-20
|
||||||
358
docs/api/endpoints.md
Normal file
358
docs/api/endpoints.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# API 接口文档
|
||||||
|
|
||||||
|
> 本项目 API 接口说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、接口概览
|
||||||
|
|
||||||
|
| 模块 | 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 候选人 | `/api/candidates` | POST | 提交候选人信息(上传简历) |
|
||||||
|
| 房间 | `/api/rooms` | POST | 创建语音房间 |
|
||||||
|
| 管理 | `/api/candidates` | GET | 获取候选人列表 |
|
||||||
|
| 管理 | `/api/candidates/:id` | GET | 获取候选人详情 |
|
||||||
|
| 管理 | `/api/candidates/:id/export` | GET | 导出 PDF 报告 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、通用说明
|
||||||
|
|
||||||
|
### 2.1 基础 URL
|
||||||
|
|
||||||
|
```
|
||||||
|
开发环境:http://localhost:8000
|
||||||
|
生产环境:https://api.your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 响应格式
|
||||||
|
|
||||||
|
**成功响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 20001,
|
||||||
|
"message": "参数错误",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 错误码
|
||||||
|
|
||||||
|
| 范围 | 类型 |
|
||||||
|
|------|------|
|
||||||
|
| `0` | 成功 |
|
||||||
|
| `10000-19999` | 用户相关错误 |
|
||||||
|
| `20000-29999` | 业务逻辑错误 |
|
||||||
|
| `30000-39999` | 数据错误 |
|
||||||
|
| `40000-49999` | 系统错误 |
|
||||||
|
| `50000-59999` | 第三方服务错误 |
|
||||||
|
|
||||||
|
常用错误码:
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `20001` | 参数错误 |
|
||||||
|
| `20002` | 数据不存在 |
|
||||||
|
| `50001` | Coze API 调用失败 |
|
||||||
|
| `50002` | RTC 服务异常 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、用户端接口
|
||||||
|
|
||||||
|
### 3.1 提交候选人信息
|
||||||
|
|
||||||
|
提交候选人姓名和简历,创建面试会话。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
- **URL**: `POST /api/candidates`
|
||||||
|
- **Content-Type**: `multipart/form-data`
|
||||||
|
|
||||||
|
**请求参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| name | string | 是 | 候选人姓名,2-20字符 |
|
||||||
|
| resume | file | 是 | 简历文件,支持 PDF/DOC/DOCX,≤10MB |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"sessionId": "SESS_1705737600_张三_abc123",
|
||||||
|
"fileId": "file_xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**处理流程**
|
||||||
|
|
||||||
|
1. 校验参数
|
||||||
|
2. 上传简历到 Coze(`POST /v1/files/upload`)
|
||||||
|
3. 生成 sessionId
|
||||||
|
4. 返回结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 创建语音房间
|
||||||
|
|
||||||
|
创建 RTC 房间,让 Coze Bot 加入房间。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
- **URL**: `POST /api/rooms`
|
||||||
|
- **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
**请求参数**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "SESS_1705737600_张三_abc123",
|
||||||
|
"fileId": "file_xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| sessionId | string | 是 | 会话 ID |
|
||||||
|
| fileId | string | 是 | 简历文件 ID |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"roomId": "room_xxx",
|
||||||
|
"token": "rtc_token_xxx",
|
||||||
|
"appId": "volcengine_app_id",
|
||||||
|
"userId": "user_xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**处理流程**
|
||||||
|
|
||||||
|
1. 生成 roomId 和 userId
|
||||||
|
2. 调用 Coze API 让 Bot 加入房间(`POST /v1/audio/rooms`)
|
||||||
|
3. 生成 RTC Token
|
||||||
|
4. 返回房间信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 结束面试
|
||||||
|
|
||||||
|
通知后端面试已结束。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
- **URL**: `POST /api/interviews/:sessionId/end`
|
||||||
|
- **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
**请求参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| sessionId | string | 是 | 会话 ID(URL 参数) |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、管理后台接口
|
||||||
|
|
||||||
|
### 4.1 获取候选人列表
|
||||||
|
|
||||||
|
分页获取候选人列表。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
- **URL**: `GET /api/candidates`
|
||||||
|
- **权限**: 需要登录
|
||||||
|
|
||||||
|
**请求参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| page | number | 否 | 页码,默认 1 |
|
||||||
|
| pageSize | number | 否 | 每页条数,默认 20 |
|
||||||
|
| keyword | string | 否 | 搜索关键词(姓名) |
|
||||||
|
| status | string | 否 | 状态筛选:pending/ongoing/completed |
|
||||||
|
| startDate | string | 否 | 开始日期,格式 YYYY-MM-DD |
|
||||||
|
| endDate | string | 否 | 结束日期,格式 YYYY-MM-DD |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"sessionId": "SESS_1705737600_张三_abc123",
|
||||||
|
"name": "张三",
|
||||||
|
"status": "completed",
|
||||||
|
"score": 85,
|
||||||
|
"createdAt": "2026-01-20T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 获取候选人详情
|
||||||
|
|
||||||
|
获取候选人完整面试报告。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
- **URL**: `GET /api/candidates/:sessionId`
|
||||||
|
- **权限**: 需要登录
|
||||||
|
|
||||||
|
**请求参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| sessionId | string | 是 | 会话 ID(URL 参数) |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"sessionId": "SESS_1705737600_张三_abc123",
|
||||||
|
"name": "张三",
|
||||||
|
"resume": "简历内容文本...",
|
||||||
|
"status": "completed",
|
||||||
|
"currentStage": 60,
|
||||||
|
"scores": {
|
||||||
|
"salesSkill": 85,
|
||||||
|
"salesMindset": 80,
|
||||||
|
"quality": 90,
|
||||||
|
"motivation": 75
|
||||||
|
},
|
||||||
|
"analysis": "综合分析报告内容...",
|
||||||
|
"interviewLog": "完整对话记录...",
|
||||||
|
"createdAt": "2026-01-20T10:00:00+08:00",
|
||||||
|
"completedAt": "2026-01-20T10:25:00+08:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 导出 PDF 报告
|
||||||
|
|
||||||
|
导出候选人面试报告为 PDF 文件。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
- **URL**: `GET /api/candidates/:sessionId/export`
|
||||||
|
- **权限**: 需要登录
|
||||||
|
|
||||||
|
**请求参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| sessionId | string | 是 | 会话 ID(URL 参数) |
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
- **Content-Type**: `application/pdf`
|
||||||
|
- **Content-Disposition**: `attachment; filename="interview_report_张三.pdf"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Coze API 调用说明
|
||||||
|
|
||||||
|
> 以下为后端调用 Coze API 的内部说明
|
||||||
|
|
||||||
|
### 5.1 文件上传
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://api.coze.cn/v1/files/upload
|
||||||
|
Authorization: Bearer {PAT_TOKEN}
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "file_xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 创建语音房间
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://api.coze.cn/v1/audio/rooms
|
||||||
|
Authorization: Bearer {PAT_TOKEN}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"bot_id": "7595077233002840079",
|
||||||
|
"room_id": "room_xxx",
|
||||||
|
"user_id": "user_xxx",
|
||||||
|
"voice_id": "voice_id_xxx",
|
||||||
|
"config": {
|
||||||
|
"input_file_id": "file_xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 查询数据库
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://api.coze.cn/v1/database/query
|
||||||
|
Authorization: Bearer {PAT_TOKEN}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"database_id": "7595077053909712922",
|
||||||
|
"filter": {
|
||||||
|
"session_id": "SESS_xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作人 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 2026-01-20 | 初始化 API 文档 | AI |
|
||||||
150
docs/database/表结构.md
Normal file
150
docs/database/表结构.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# 数据库结构
|
||||||
|
|
||||||
|
> 本项目使用 Coze 数据库存储数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、数据库信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 平台 | Coze 数据库 |
|
||||||
|
| 数据库 ID | 7595077053909712922 |
|
||||||
|
| 说明 | 由 Coze 工作流自动写入和管理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、表结构
|
||||||
|
|
||||||
|
### 2.1 候选人面试记录表
|
||||||
|
|
||||||
|
> 表名由 Coze 自动管理
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 | 示例 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| session_id | String | 会话唯一标识 | `SESS_1705737600_张三_abc123` |
|
||||||
|
| 姓名 | String | 候选人姓名 | `张三` |
|
||||||
|
| 简历内容 | String | 解析后的简历文本 | `姓名:张三,学历:本科...` |
|
||||||
|
| current_stage | Integer | 当前面试阶段 | `60` |
|
||||||
|
| 面试记录 | String | 完整对话记录(JSON) | `[{"role":"ai","content":"..."}]` |
|
||||||
|
| 评分结果 | String | 各维度评分(JSON) | `{"salesSkill":85,...}` |
|
||||||
|
| 分析报告 | String | 综合分析报告 | `候选人具备良好的销售潜质...` |
|
||||||
|
| created_at | Timestamp | 创建时间 | `2026-01-20T10:00:00Z` |
|
||||||
|
| updated_at | Timestamp | 更新时间 | `2026-01-20T10:25:00Z` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、字段详细说明
|
||||||
|
|
||||||
|
### 3.1 session_id 格式
|
||||||
|
|
||||||
|
```
|
||||||
|
SESS_{时间戳}_{姓名}_{随机码}
|
||||||
|
|
||||||
|
示例:SESS_1705737600_张三_abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
- 时间戳:Unix 时间戳(秒)
|
||||||
|
- 姓名:候选人姓名
|
||||||
|
- 随机码:6位随机字符串
|
||||||
|
|
||||||
|
### 3.2 current_stage 阶段码
|
||||||
|
|
||||||
|
| 阶段码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 10 | 信息收集 |
|
||||||
|
| 20 | 销售技能面试 |
|
||||||
|
| 30 | 销售观面试 |
|
||||||
|
| 40 | 素质项面试 |
|
||||||
|
| 50 | 求职动机面试 |
|
||||||
|
| 60 | 面试完成 |
|
||||||
|
|
||||||
|
### 3.3 评分结果结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"salesSkill": 85, // 销售技能 0-100
|
||||||
|
"salesMindset": 80, // 销售观 0-100
|
||||||
|
"quality": 90, // 素质项 0-100
|
||||||
|
"motivation": 75, // 求职动机 0-100
|
||||||
|
"total": 82.5 // 加权总分
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
权重计算:
|
||||||
|
- 销售技能:30%
|
||||||
|
- 销售观:25%
|
||||||
|
- 素质项:25%
|
||||||
|
- 求职动机:20%
|
||||||
|
|
||||||
|
### 3.4 面试记录结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "ai",
|
||||||
|
"content": "你好,我是AI面试官,请先做个自我介绍。",
|
||||||
|
"timestamp": "2026-01-20T10:01:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "你好,我叫张三...",
|
||||||
|
"timestamp": "2026-01-20T10:01:30Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、数据访问方式
|
||||||
|
|
||||||
|
### 4.1 查询单条记录
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 后端调用 Coze API
|
||||||
|
response = await coze_client.post(
|
||||||
|
"/v1/database/query",
|
||||||
|
json={
|
||||||
|
"database_id": "7595077053909712922",
|
||||||
|
"filter": {
|
||||||
|
"session_id": "SESS_xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 查询列表(分页)
|
||||||
|
|
||||||
|
```python
|
||||||
|
response = await coze_client.post(
|
||||||
|
"/v1/database/query",
|
||||||
|
json={
|
||||||
|
"database_id": "7595077053909712922",
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"sort": {
|
||||||
|
"field": "created_at",
|
||||||
|
"order": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、数据写入
|
||||||
|
|
||||||
|
> 数据写入由 Coze 工作流自动完成,后端只负责读取
|
||||||
|
|
||||||
|
工作流写入节点:
|
||||||
|
- 节点 ID:`124924`
|
||||||
|
- 类型:`insert_record`
|
||||||
|
- 触发时机:候选人提交信息后
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作人 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 2026-01-20 | 初始化数据库文档 | AI |
|
||||||
75
docs/mock-resume.md
Normal file
75
docs/mock-resume.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 个人简历
|
||||||
|
|
||||||
|
## 基本信息
|
||||||
|
|
||||||
|
- **姓名**:李小美
|
||||||
|
- **性别**:女
|
||||||
|
- **年龄**:26岁
|
||||||
|
- **学历**:本科
|
||||||
|
- **电话**:138-8888-9999
|
||||||
|
- **邮箱**:lixiaomei@email.com
|
||||||
|
- **现居地**:上海市静安区
|
||||||
|
- **求职意向**:轻医美咨询师 / 美容顾问
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 教育背景
|
||||||
|
|
||||||
|
**上海工商大学** | 市场营销专业 | 本科 | 2018年9月 - 2022年6月
|
||||||
|
|
||||||
|
- 主修课程:市场营销学、消费者心理学、品牌管理、商务谈判
|
||||||
|
- 在校期间多次获得奖学金,担任班级学习委员
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作经历
|
||||||
|
|
||||||
|
### 某知名美容连锁机构 | 美容顾问 | 2022年7月 - 2024年12月
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- 负责门店客户接待和咨询服务,月均接待客户80+人次
|
||||||
|
- 根据客户肤质和需求,推荐适合的护肤项目和产品方案
|
||||||
|
- 跟进客户体验反馈,维护老客户关系,客户回头率达65%
|
||||||
|
- 协助店长完成月度销售目标,个人业绩连续6个月排名前三
|
||||||
|
|
||||||
|
**主要成绩**:
|
||||||
|
- 2023年度"最佳新人奖"
|
||||||
|
- 单月最高业绩28万元
|
||||||
|
- 发展VIP客户50+人
|
||||||
|
|
||||||
|
### 某护肤品品牌专柜 | 销售顾问 | 2021年6月 - 2022年6月(实习)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- 在商场专柜负责产品销售和客户服务
|
||||||
|
- 学习专业护肤知识,为客户提供皮肤检测和护理建议
|
||||||
|
- 参与品牌促销活动策划和执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 专业技能
|
||||||
|
|
||||||
|
- **护肤知识**:熟悉各类肤质特点,了解常见皮肤问题的形成原因和改善方案
|
||||||
|
- **医美知识**:了解光电类、注射类等常见轻医美项目的原理和适应人群
|
||||||
|
- **沟通能力**:善于倾听客户需求,能够用通俗易懂的语言解释专业知识
|
||||||
|
- **销售技巧**:熟悉FABE销售法则,擅长挖掘客户需求促成成交
|
||||||
|
- **办公软件**:熟练使用Office办公软件、CRM系统
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 证书资质
|
||||||
|
|
||||||
|
- 高级美容师资格证(2023年)
|
||||||
|
- 皮肤管理师认证(2022年)
|
||||||
|
- 普通话二级甲等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自我评价
|
||||||
|
|
||||||
|
热爱美业,对轻医美行业充满热情。性格开朗、亲和力强,善于与不同类型的客户建立信任关系。具备2年多美容行业一线销售经验,熟悉客户接待全流程。学习能力强,愿意不断提升专业知识。期望加入贵公司,在轻医美领域深耕发展,为客户提供专业的咨询服务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 期望薪资
|
||||||
|
|
||||||
|
8000-12000元/月(底薪+提成)
|
||||||
94
docs/nginx-files-server.conf
Normal file
94
docs/nginx-files-server.conf
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
server
|
||||||
|
{
|
||||||
|
listen 80;
|
||||||
|
server_name files.test.ai.ireborn.com.cn;
|
||||||
|
index index.php index.html index.htm default.php default.htm default.html;
|
||||||
|
root /www/wwwroot/files.test.ai.ireborn.com.cn;
|
||||||
|
|
||||||
|
# 文件上传大小限制(20MB)
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
#CERT-APPLY-CHECK--START
|
||||||
|
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除
|
||||||
|
include /www/server/panel/vhost/nginx/well-known/files.test.ai.ireborn.com.cn.conf;
|
||||||
|
#CERT-APPLY-CHECK--END
|
||||||
|
include /www/server/panel/vhost/nginx/extension/files.test.ai.ireborn.com.cn/*.conf;
|
||||||
|
|
||||||
|
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
|
||||||
|
#error_page 404/404.html;
|
||||||
|
#SSL-END
|
||||||
|
|
||||||
|
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
|
||||||
|
error_page 404 /404.html;
|
||||||
|
#error_page 502 /502.html;
|
||||||
|
#ERROR-PAGE-END
|
||||||
|
|
||||||
|
#PHP-INFO-START PHP引用配置,可以注释或修改
|
||||||
|
include enable-php-82.conf;
|
||||||
|
#PHP-INFO-END
|
||||||
|
|
||||||
|
#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
|
||||||
|
include /www/server/panel/vhost/rewrite/files.test.ai.ireborn.com.cn.conf;
|
||||||
|
#REWRITE-END
|
||||||
|
|
||||||
|
# ============ 简历文件服务配置 ============
|
||||||
|
location /resumes/ {
|
||||||
|
alias /www/wwwroot/files.test.ai.ireborn.com.cn/resumes/;
|
||||||
|
|
||||||
|
# 跨域配置(Coze 需要)
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS';
|
||||||
|
add_header Access-Control-Allow-Headers 'Content-Type, Authorization';
|
||||||
|
|
||||||
|
# 处理 OPTIONS 预检请求
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS';
|
||||||
|
add_header Access-Control-Allow-Headers 'Content-Type, Authorization';
|
||||||
|
add_header Content-Length 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 支持 PDF 文件
|
||||||
|
types {
|
||||||
|
application/pdf pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 禁止目录浏览
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
# ============ 简历文件服务配置结束 ============
|
||||||
|
|
||||||
|
#禁止访问的文件或目录
|
||||||
|
location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md)
|
||||||
|
{
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
#一键申请SSL证书验证目录相关设置
|
||||||
|
location ~ \.well-known{
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#禁止在证书验证目录放入敏感文件
|
||||||
|
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$
|
||||||
|
{
|
||||||
|
expires 30d;
|
||||||
|
error_log /dev/null;
|
||||||
|
access_log /dev/null;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ .*\.(js|css)?$
|
||||||
|
{
|
||||||
|
expires 12h;
|
||||||
|
error_log /dev/null;
|
||||||
|
access_log /dev/null;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/files.test.ai.ireborn.com.cn.log;
|
||||||
|
error_log /www/wwwlogs/files.test.ai.ireborn.com.cn.error.log;
|
||||||
|
}
|
||||||
93
docs/upload.php
Normal file
93
docs/upload.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 简历文件上传接口
|
||||||
|
*
|
||||||
|
* 放置位置: /www/wwwroot/files.test.ai.ireborn.com.cn/upload.php
|
||||||
|
*
|
||||||
|
* 请求方式: POST
|
||||||
|
* 参数:
|
||||||
|
* - file: 文件(multipart/form-data)
|
||||||
|
* - token: 验证令牌(防止滥用)
|
||||||
|
*
|
||||||
|
* 返回:
|
||||||
|
* - {"code": 0, "url": "http://files.test.ai.ireborn.com.cn/resumes/xxx.pdf"}
|
||||||
|
* - {"code": 1, "error": "错误信息"}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
$UPLOAD_DIR = '/www/wwwroot/files.test.ai.ireborn.com.cn/resumes/';
|
||||||
|
$BASE_URL = 'http://files.test.ai.ireborn.com.cn/resumes/';
|
||||||
|
$SECRET_TOKEN = 'your_secret_token_here_change_me'; // 请修改为你自己的密钥
|
||||||
|
$MAX_SIZE = 20 * 1024 * 1024; // 20MB
|
||||||
|
$ALLOWED_TYPES = ['application/pdf'];
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
|
||||||
|
// 处理 OPTIONS 预检请求
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只允许 POST 请求
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
echo json_encode(['code' => 1, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证令牌
|
||||||
|
$token = $_POST['token'] ?? $_SERVER['HTTP_X_UPLOAD_TOKEN'] ?? '';
|
||||||
|
if ($token !== $SECRET_TOKEN) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['code' => 1, 'error' => 'Invalid token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否上传
|
||||||
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$error_msg = isset($_FILES['file']) ? 'Upload error: ' . $_FILES['file']['error'] : 'No file uploaded';
|
||||||
|
echo json_encode(['code' => 1, 'error' => $error_msg]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['file'];
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
if ($file['size'] > $MAX_SIZE) {
|
||||||
|
echo json_encode(['code' => 1, 'error' => 'File too large (max 20MB)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件类型(通过扩展名)
|
||||||
|
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||||
|
if ($ext !== 'pdf') {
|
||||||
|
echo json_encode(['code' => 1, 'error' => 'Invalid file type. Only PDF allowed.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
$file_id = 'resume_' . bin2hex(random_bytes(6));
|
||||||
|
$filename = $file_id . '.pdf';
|
||||||
|
$filepath = $UPLOAD_DIR . $filename;
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if (!is_dir($UPLOAD_DIR)) {
|
||||||
|
mkdir($UPLOAD_DIR, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动文件
|
||||||
|
if (move_uploaded_file($file['tmp_name'], $filepath)) {
|
||||||
|
$url = $BASE_URL . $filename;
|
||||||
|
echo json_encode([
|
||||||
|
'code' => 0,
|
||||||
|
'url' => $url,
|
||||||
|
'file_id' => $file_id,
|
||||||
|
'filename' => $filename
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['code' => 1, 'error' => 'Failed to save file']);
|
||||||
|
}
|
||||||
70
docs/决策记录.md
Normal file
70
docs/决策记录.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 决策记录
|
||||||
|
|
||||||
|
> ⛔ **版本规则**:只允许追加,禁止修改/删除已有记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策索引
|
||||||
|
|
||||||
|
| 编号 | 日期 | 主题 | 决策结果 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| DR-001 | 2026-01-20 | 技术选型与框架规范覆盖 | 使用 Coze API + 火山引擎 RTC |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DR-001 | 2026-01-20
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
|
||||||
|
项目需要实现 AI 语音面试系统。已有完整的 Coze 工作流(工作流 ID:7595077233002840079),包含面试逻辑、评分算法、报告生成等功能。需要确定技术选型,并明确与 AgentWD 框架规范的差异。
|
||||||
|
|
||||||
|
### 方案选项
|
||||||
|
|
||||||
|
| 选项 | 方案描述 | 优点 | 缺点 |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| A | 全部遵循框架规范,使用 OpenRouter + MySQL | 完全符合框架规范 | 需重新开发面试逻辑,工作量大 |
|
||||||
|
| B | 复用 Coze 工作流,覆盖部分规范 | 开发效率高,复用现有资产 | 与框架规范有差异 |
|
||||||
|
|
||||||
|
### 多角色分析
|
||||||
|
|
||||||
|
**产品经理**:
|
||||||
|
- 分析:项目核心价值在于 AI 面试能力,Coze 工作流已经过验证
|
||||||
|
- 建议:复用现有工作流,快速上线
|
||||||
|
- 倾向:方案 B
|
||||||
|
|
||||||
|
**架构师**:
|
||||||
|
- 分析:Coze + 火山引擎 RTC 同属火山生态,集成成本低
|
||||||
|
- 建议:前后端遵循框架规范,仅 AI 层使用 Coze
|
||||||
|
- 倾向:方案 B
|
||||||
|
|
||||||
|
**前端开发**:
|
||||||
|
- 分析:Vue3 + Element Plus 符合框架规范,无额外学习成本
|
||||||
|
- 建议:前端完全遵循框架规范
|
||||||
|
- 倾向:方案 B
|
||||||
|
|
||||||
|
### 最终决策
|
||||||
|
|
||||||
|
**决策结果**:选择方案 B
|
||||||
|
|
||||||
|
**决策原因**:
|
||||||
|
1. Coze 工作流已包含完整面试逻辑,复用可大幅减少开发时间
|
||||||
|
2. 前端、后端框架仍遵循规范,仅 AI 服务层使用 Coze
|
||||||
|
3. 火山引擎 RTC 与 Coze 同生态,官方支持对接
|
||||||
|
4. 在 `docs/技术选型.md` 中明确声明覆盖项
|
||||||
|
|
||||||
|
**否决方案**:
|
||||||
|
- 方案 A 被否决,原因:需要重新开发 AI 面试逻辑,工作量大,且已有成熟方案
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
|
||||||
|
- [x] 技术选型文档需创建
|
||||||
|
- [ ] PRD 无需更新
|
||||||
|
- [ ] 原型无需更新
|
||||||
|
|
||||||
|
### 来源对话框
|
||||||
|
|
||||||
|
第 1 对话框
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- 新决策从这里开始追加 -->
|
||||||
140
docs/同步清单.md
Normal file
140
docs/同步清单.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# 同步清单
|
||||||
|
|
||||||
|
> 多对话框协作的核心同步文件
|
||||||
|
> ✅ 可覆盖,需记录变更日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目状态
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **当前阶段** | 开发阶段 |
|
||||||
|
| **进度** | 65% |
|
||||||
|
| **最后更新** | 2026-01-20 16:30 |
|
||||||
|
| **当前对话框** | 第1对话框 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待办事项
|
||||||
|
|
||||||
|
### 🔴 紧急(今日必须完成)
|
||||||
|
|
||||||
|
- [ ] 确认 PRD 待确认事项(管理后台登录、面试时长等)
|
||||||
|
|
||||||
|
### 🟡 重要(本周完成)
|
||||||
|
|
||||||
|
- [ ] 初始化前端项目(Vue3 + Vite)
|
||||||
|
- [ ] 初始化后端项目(FastAPI)
|
||||||
|
- [ ] 实现后端 Coze API 封装
|
||||||
|
- [ ] 实现后端 RTC Token 生成
|
||||||
|
|
||||||
|
### 🟢 普通(待安排)
|
||||||
|
|
||||||
|
- [ ] 前端用户端页面开发(5个页面)
|
||||||
|
- [ ] 前端管理后台页面开发(2个页面)
|
||||||
|
- [ ] 前端 RTC SDK 集成
|
||||||
|
- [ ] 联调测试
|
||||||
|
- [ ] 部署上线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进行中的任务
|
||||||
|
|
||||||
|
| 任务 | 负责角色 | 开始时间 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 需求分析 | 产品经理 | 2026-01-20 | ✅ 完成 |
|
||||||
|
| 技术选型 | 架构师 | 2026-01-20 | ✅ 完成 |
|
||||||
|
| 文档整理 | AI | 2026-01-20 | ✅ 完成 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已完成任务
|
||||||
|
|
||||||
|
| 任务 | 完成时间 | 备注 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 创建项目目录结构 | 2026-01-20 | 按框架规范创建 |
|
||||||
|
| 初始化核心文档 | 2026-01-20 | CONTEXT.md 等 |
|
||||||
|
| 撰写 PRD | 2026-01-20 | v1.0 |
|
||||||
|
| 技术选型决策 | 2026-01-20 | DR-001,使用 Coze API |
|
||||||
|
| API 文档设计 | 2026-01-20 | 5 个接口 |
|
||||||
|
| 数据库结构文档 | 2026-01-20 | Coze 数据库 |
|
||||||
|
| 前端项目骨架 | 2026-01-20 | Vue3 + Vite + Element Plus |
|
||||||
|
| 后端项目骨架 | 2026-01-20 | FastAPI + Coze API 封装 |
|
||||||
|
| Coze 工作流分析 | 2026-01-20 | 详见 coze-workflows/工作流分析.md |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阻塞问题
|
||||||
|
|
||||||
|
| 问题 | 阻塞原因 | 待处理人 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 管理后台数据查询 | Coze 数据库无 REST API | 产品 | 待讨论方案 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决策统计
|
||||||
|
|
||||||
|
| 项目 | 数量 |
|
||||||
|
|------|------|
|
||||||
|
| 总决策数 | 1 |
|
||||||
|
| 本周新增 | 1 |
|
||||||
|
| 待讨论 | 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发任务清单
|
||||||
|
|
||||||
|
### 阶段 1:项目初始化
|
||||||
|
|
||||||
|
- [ ] 创建前端项目(Vue3 + TypeScript + Vite)
|
||||||
|
- [ ] 安装前端依赖(Element Plus、Tailwind、Axios、RTC SDK)
|
||||||
|
- [ ] 创建后端项目(FastAPI)
|
||||||
|
- [ ] 安装后端依赖(httpx、python-multipart)
|
||||||
|
- [ ] 配置环境变量
|
||||||
|
|
||||||
|
### 阶段 2:后端核心功能
|
||||||
|
|
||||||
|
- [ ] 实现 Coze API 封装(文件上传、数据库查询)
|
||||||
|
- [ ] 实现 RTC 服务封装(Token 生成)
|
||||||
|
- [ ] 实现 `POST /api/candidates` 接口
|
||||||
|
- [ ] 实现 `POST /api/rooms` 接口
|
||||||
|
- [ ] 实现 `GET /api/candidates` 接口
|
||||||
|
- [ ] 实现 `GET /api/candidates/:id` 接口
|
||||||
|
|
||||||
|
### 阶段 3:前端用户端
|
||||||
|
|
||||||
|
- [ ] 实现欢迎页(Welcome)
|
||||||
|
- [ ] 实现信息收集页(InfoCollection)
|
||||||
|
- [ ] 实现模拟来电页(IncomingCall)
|
||||||
|
- [ ] 集成火山引擎 RTC SDK
|
||||||
|
- [ ] 实现语音通话页(InCall)
|
||||||
|
- [ ] 实现面试结束页(Completed)
|
||||||
|
|
||||||
|
### 阶段 4:前端管理后台
|
||||||
|
|
||||||
|
- [ ] 实现候选人列表页
|
||||||
|
- [ ] 实现候选人详情页
|
||||||
|
- [ ] 实现评分雷达图组件
|
||||||
|
- [ ] 实现 PDF 导出功能
|
||||||
|
|
||||||
|
### 阶段 5:联调测试
|
||||||
|
|
||||||
|
- [ ] 端到端流程测试
|
||||||
|
- [ ] 语音质量测试
|
||||||
|
- [ ] 异常场景测试
|
||||||
|
|
||||||
|
### 阶段 6:部署上线
|
||||||
|
|
||||||
|
- [ ] 编写 Dockerfile
|
||||||
|
- [ ] 配置 Nginx
|
||||||
|
- [ ] 部署到服务器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作人 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 2026-01-20 | 更新任务清单,添加开发阶段 | AI |
|
||||||
|
| 2026-01-20 | 初始化同步清单 | AI |
|
||||||
121
docs/技术选型.md
Normal file
121
docs/技术选型.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# 技术选型
|
||||||
|
|
||||||
|
> 本项目技术选型说明,包含与框架规范的覆盖声明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、遵循框架规范
|
||||||
|
|
||||||
|
| 层级 | 技术 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **前端框架** | Vue 3 + TypeScript | 符合框架规范 |
|
||||||
|
| **构建工具** | Vite | 符合框架规范 |
|
||||||
|
| **UI 组件库** | Element Plus | 符合框架规范 |
|
||||||
|
| **CSS 方案** | Tailwind CSS | 符合框架规范 |
|
||||||
|
| **HTTP 客户端** | Axios | 符合框架规范 |
|
||||||
|
| **状态管理** | Pinia | 按需使用 |
|
||||||
|
| **后端框架** | Python + FastAPI | 符合框架规范 |
|
||||||
|
| **容器化** | Docker + Docker Compose | 符合框架规范 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、技术选型覆盖
|
||||||
|
|
||||||
|
> 以下选项与框架默认规范不同,需特别说明
|
||||||
|
|
||||||
|
| 项目 | 框架默认 | 本项目选择 | 覆盖原因 |
|
||||||
|
|------|----------|------------|----------|
|
||||||
|
| **AI 网关** | OpenRouter.ai | Coze API | 复用已有 Coze 工作流,包含完整面试逻辑 |
|
||||||
|
| **数据存储** | MySQL | Coze 数据库 | 复用现有数据结构,减少开发工作量 |
|
||||||
|
| **实时音视频** | - | 火山引擎 RTC | 与 Coze 同属火山引擎生态,官方支持对接 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、第三方服务依赖
|
||||||
|
|
||||||
|
### 3.1 Coze 平台
|
||||||
|
|
||||||
|
| 资源 | ID | 用途 |
|
||||||
|
|------|-----|------|
|
||||||
|
| Bot(工作流) | 7595113005181386792 | AI 面试官对话逻辑 |
|
||||||
|
| 数据库 | 7595077053909712922 | 候选人数据存储 |
|
||||||
|
|
||||||
|
**API 端点**:
|
||||||
|
- 基础 URL:`https://api.coze.cn`
|
||||||
|
- 文件上传:`POST /v1/files/upload`
|
||||||
|
- 语音房间:`POST /v1/audio/rooms`
|
||||||
|
- 数据库查询:`POST /v1/database/query`
|
||||||
|
|
||||||
|
### 3.2 火山引擎 RTC
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| SDK | `@volcengine/rtc` |
|
||||||
|
| 音频格式 | PCM 16000Hz |
|
||||||
|
| 用途 | 前端与 Coze Bot 实时语音通话 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Coze 配置
|
||||||
|
COZE_API_BASE=https://api.coze.cn
|
||||||
|
COZE_PAT_TOKEN=pat_xxx # 从 Coze 平台获取
|
||||||
|
COZE_BOT_ID=7595077233002840079
|
||||||
|
COZE_DATABASE_ID=7595077053909712922
|
||||||
|
|
||||||
|
# 火山引擎 RTC 配置
|
||||||
|
VOLCENGINE_APP_ID=xxx # 从火山引擎控制台获取
|
||||||
|
VOLCENGINE_APP_KEY=xxx
|
||||||
|
|
||||||
|
# 服务配置
|
||||||
|
API_PORT=8000
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、前端依赖清单
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.2.0",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"element-plus": "^2.5.0",
|
||||||
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
|
"@volcengine/rtc": "^4.x"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、后端依赖清单
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# requirements.txt
|
||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn>=0.27.0
|
||||||
|
httpx>=0.26.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
pydantic>=2.5.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作人 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 2026-01-20 | 初始化技术选型 | AI |
|
||||||
206
docs/部署文档.md
Normal file
206
docs/部署文档.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# AI 语音面试系统 - 部署文档
|
||||||
|
|
||||||
|
> 更新日期: 2026-01-21
|
||||||
|
> 状态: ✅ 已部署
|
||||||
|
|
||||||
|
## 一、服务器信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 服务器 IP | 47.107.172.23 |
|
||||||
|
| SSH 用户名 | root |
|
||||||
|
| SSH 密码 | Nj861021 |
|
||||||
|
| 宝塔面板 | http://47.107.172.23:8888/ |
|
||||||
|
| 宝塔 API Token | PKdfnaInQL0P5ghB8SvwbrGcIpXWaEvq |
|
||||||
|
| 域名解析 | *.ai.ireborn.com.cn → 47.107.172.23 |
|
||||||
|
| 测试域名 | *.test.ai.ireborn.com.cn |
|
||||||
|
|
||||||
|
## 二、部署地址 (已上线)
|
||||||
|
|
||||||
|
| 服务 | 地址 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户端 | http://interview.test.ai.ireborn.com.cn | ✅ |
|
||||||
|
| 管理后台 | http://interview.test.ai.ireborn.com.cn/admin | ✅ |
|
||||||
|
| 后端 API | http://interview.test.ai.ireborn.com.cn/api/health | ✅ |
|
||||||
|
| 文件服务 | https://files.test.ai.ireborn.com.cn | ✅ |
|
||||||
|
|
||||||
|
> ⚠️ 当前为简化版后端,完整功能代码需要手动上传后重新构建
|
||||||
|
|
||||||
|
## 三、Docker 部署
|
||||||
|
|
||||||
|
### 3.1 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/www/wwwroot/ai-interview/
|
||||||
|
├── deploy/
|
||||||
|
│ ├── docker-compose.yml
|
||||||
|
│ ├── Dockerfile.frontend
|
||||||
|
│ ├── Dockerfile.backend
|
||||||
|
│ ├── nginx/
|
||||||
|
│ │ └── frontend.conf
|
||||||
|
│ └── .env
|
||||||
|
├── frontend/
|
||||||
|
├── backend/
|
||||||
|
└── uploads/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 环境变量配置
|
||||||
|
|
||||||
|
创建 `/www/wwwroot/ai-interview/deploy/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Coze 配置
|
||||||
|
COZE_PAT_TOKEN=pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT
|
||||||
|
COZE_BOT_ID=7595113005181386792
|
||||||
|
|
||||||
|
# 工作流 ID
|
||||||
|
COZE_WORKFLOW_A_ID=7597357422713798710
|
||||||
|
COZE_WORKFLOW_C_ID=7597376294612107318
|
||||||
|
|
||||||
|
# 文件服务器
|
||||||
|
FILE_SERVER_URL=https://files.test.ai.ireborn.com.cn
|
||||||
|
FILE_SERVER_TOKEN=ai_interview_2026_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 部署命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/ai-interview/deploy
|
||||||
|
|
||||||
|
# 构建并启动
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 端口映射
|
||||||
|
|
||||||
|
| 服务 | 容器端口 | 宿主机端口 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| Frontend | 80 | 3000 |
|
||||||
|
| Backend | 8000 | 8000 |
|
||||||
|
|
||||||
|
## 四、Nginx 反向代理配置
|
||||||
|
|
||||||
|
### 4.1 前端 + API 合并 (推荐)
|
||||||
|
|
||||||
|
域名: `interview.test.ai.ireborn.com.cn`
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name interview.test.ai.ireborn.com.cn;
|
||||||
|
|
||||||
|
# SSL 证书 (宝塔自动配置)
|
||||||
|
ssl_certificate /www/server/panel/vhost/cert/interview.test.ai.ireborn.com.cn/fullchain.pem;
|
||||||
|
ssl_certificate_key /www/server/panel/vhost/cert/interview.test.ai.ireborn.com.cn/privkey.pem;
|
||||||
|
|
||||||
|
# 前端
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理 (前端容器内部已处理,此处可选)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 独立 API 域名 (可选)
|
||||||
|
|
||||||
|
域名: `interview-api.test.ai.ireborn.com.cn`
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name interview-api.test.ai.ireborn.com.cn;
|
||||||
|
|
||||||
|
ssl_certificate /www/server/panel/vhost/cert/interview-api.test.ai.ireborn.com.cn/fullchain.pem;
|
||||||
|
ssl_certificate_key /www/server/panel/vhost/cert/interview-api.test.ai.ireborn.com.cn/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
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;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_connect_timeout 120s;
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
|
||||||
|
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、更新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/ai-interview
|
||||||
|
|
||||||
|
# 拉取最新代码
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 重新构建并部署
|
||||||
|
cd deploy
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## 六、故障排查
|
||||||
|
|
||||||
|
### 6.1 查看容器日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 前端日志
|
||||||
|
docker logs ai-interview-frontend
|
||||||
|
|
||||||
|
# 后端日志
|
||||||
|
docker logs ai-interview-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 进入容器调试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入后端容器
|
||||||
|
docker exec -it ai-interview-backend /bin/sh
|
||||||
|
|
||||||
|
# 进入前端容器
|
||||||
|
docker exec -it ai-interview-frontend /bin/sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 常见问题
|
||||||
|
|
||||||
|
| 问题 | 解决方案 |
|
||||||
|
|------|----------|
|
||||||
|
| 前端白屏 | 检查 nginx 配置,确保 `try_files` 指向 index.html |
|
||||||
|
| API 404 | 检查 proxy_pass 地址和端口 |
|
||||||
|
| CORS 错误 | 在 nginx 添加 CORS 头 |
|
||||||
|
| 环境变量无效 | 确认 .env 文件存在且格式正确 |
|
||||||
110
docs/项目状态快照.md
Normal file
110
docs/项目状态快照.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 项目状态快照
|
||||||
|
|
||||||
|
> 新对话框快速恢复上下文的关键文档
|
||||||
|
> ✅ 可覆盖,需记录变更日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目全貌
|
||||||
|
|
||||||
|
### 1.1 基本信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 项目名称 | AI Interview (AI面试助手) |
|
||||||
|
| 项目编号 | 011-ai-interview-2601 |
|
||||||
|
| 启动日期 | 2026-01-20 |
|
||||||
|
| 当前阶段 | 设计阶段 |
|
||||||
|
| 整体进度 | 20% |
|
||||||
|
|
||||||
|
### 1.2 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | Vue3 + TypeScript + Element Plus |
|
||||||
|
| 后端 | Python + FastAPI |
|
||||||
|
| AI 服务 | Coze API(覆盖框架规范) |
|
||||||
|
| 实时音视频 | 火山引擎 RTC |
|
||||||
|
| 数据存储 | Coze 数据库(覆盖框架规范) |
|
||||||
|
| 部署 | Docker + Nginx |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、阶段进度
|
||||||
|
|
||||||
|
| 阶段 | 状态 | 完成度 | 备注 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 需求分析 | ✅ 完成 | 100% | PRD v1.0 已完成 |
|
||||||
|
| 技术选型 | ✅ 完成 | 100% | DR-001 已决策 |
|
||||||
|
| 设计阶段 | 🔄 进行中 | 50% | API/数据库文档已完成 |
|
||||||
|
| 开发阶段 | ⏳ 待开始 | 0% | |
|
||||||
|
| 测试阶段 | ⏳ 待开始 | 0% | |
|
||||||
|
| 上线部署 | ⏳ 待开始 | 0% | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、核心决策摘要
|
||||||
|
|
||||||
|
> 详细内容见 `决策记录.md`
|
||||||
|
|
||||||
|
| 编号 | 主题 | 结果 |
|
||||||
|
|------|------|------|
|
||||||
|
| DR-001 | 技术选型与框架规范覆盖 | 使用 Coze API + 火山引擎 RTC,在技术选型中声明覆盖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、关键文档
|
||||||
|
|
||||||
|
| 文档 | 路径 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| PRD | `docs/PRD.md` | ✅ v1.0 |
|
||||||
|
| 技术选型 | `docs/技术选型.md` | ✅ |
|
||||||
|
| 决策记录 | `docs/决策记录.md` | ✅ |
|
||||||
|
| API 文档 | `docs/api/endpoints.md` | ✅ |
|
||||||
|
| 数据库结构 | `docs/database/表结构.md` | ✅ |
|
||||||
|
| 功能模块索引 | `docs/功能模块索引.md` | 📝 待创建 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、当前待办
|
||||||
|
|
||||||
|
> 详细内容见 `同步清单.md`
|
||||||
|
|
||||||
|
1. 确认 PRD 待确认事项
|
||||||
|
2. 初始化前端项目(Vue3 + Vite)
|
||||||
|
3. 初始化后端项目(FastAPI)
|
||||||
|
4. 实现后端 Coze API 封装
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、已知问题
|
||||||
|
|
||||||
|
| 问题 | 状态 | 处理方案 |
|
||||||
|
|------|------|---------|
|
||||||
|
| - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、上一对话框教训
|
||||||
|
|
||||||
|
> 从交接中沉淀的经验
|
||||||
|
|
||||||
|
- 新项目,暂无
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、现有资源
|
||||||
|
|
||||||
|
| 资源 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| Coze 工作流 | 已有完整面试逻辑(ID:7595077233002840079) |
|
||||||
|
| Coze 数据库 | 已有数据结构(ID:7595077053909712922) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更内容 | 操作人 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 2026-01-20 | 更新阶段进度,添加技术栈详情 | AI |
|
||||||
|
| 2026-01-20 | 初始化快照 | AI |
|
||||||
174
docs/项目进度总结-20260121-final.md
Normal file
174
docs/项目进度总结-20260121-final.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# AI 语音面试系统 - 项目进度总结
|
||||||
|
|
||||||
|
> 更新日期: 2026-01-21
|
||||||
|
|
||||||
|
## 一、项目概述
|
||||||
|
|
||||||
|
**项目名称**: AI 语音面试系统 (轻医美行业)
|
||||||
|
**项目周期**: 2026-01 至今
|
||||||
|
**当前状态**: 🟡 开发中 (核心功能已完成,语音模式待优化)
|
||||||
|
|
||||||
|
## 二、已完成功能
|
||||||
|
|
||||||
|
### 2.1 用户端 (前端)
|
||||||
|
|
||||||
|
| 功能 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 欢迎页面 | ✅ | 品牌展示、开始面试入口 |
|
||||||
|
| 信息采集 | ✅ | 姓名输入、简历 PDF 上传 |
|
||||||
|
| 面试初始化 | ✅ | 调用 Workflow A 生成 session_id |
|
||||||
|
| 文字面试模式 | ✅ | 基于 /v3/chat API 的文字对话 |
|
||||||
|
| 语音面试模式 | 🟡 | RTC 连接正常,session_id 传递待优化 |
|
||||||
|
| 调试面板 | ✅ | 显示 session_id、API 响应、错误信息 |
|
||||||
|
|
||||||
|
### 2.2 管理后台
|
||||||
|
|
||||||
|
| 功能 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 登录页面 | ✅ | 账号密码认证 |
|
||||||
|
| 数据概览 | ✅ | 统计卡片、匹配度分布、状态饼图、排行榜 |
|
||||||
|
| 面试列表 | ✅ | 搜索、筛选、分页、阶段标签 |
|
||||||
|
| 面试详情 | ✅ | 匹配度圆环、维度评分、风险分析、对话记录 |
|
||||||
|
| 骨架屏加载 | ✅ | 优化加载体验,替代 v-loading 遮罩 |
|
||||||
|
|
||||||
|
### 2.3 后端服务
|
||||||
|
|
||||||
|
| 功能 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 文件上传 | ✅ | 支持 PDF,上传到自建文件服务器 |
|
||||||
|
| 面试初始化 API | ✅ | 调用 Workflow A,返回 session_id |
|
||||||
|
| 文字聊天 API | ✅ | 调用 /v3/chat,支持多轮对话 |
|
||||||
|
| 语音房间 API | ✅ | 调用 /v1/audio/rooms 创建 RTC 房间 |
|
||||||
|
| 管理后台 API | ✅ | 通过 Workflow C 查询 Coze 数据库 |
|
||||||
|
|
||||||
|
### 2.4 Coze 工作流
|
||||||
|
|
||||||
|
| 工作流 | ID | 功能 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| Workflow A | 7597357422713798710 | 面试初始化:接收 name + file_url,解析简历,生成 session_id |
|
||||||
|
| Workflow B | 7595077233002840079 | 面试主流程:4 维度提问、评分、生成报告 |
|
||||||
|
| Workflow C | 7597376294612107318 | 数据查询:接收 table + sql,执行增删改查 |
|
||||||
|
|
||||||
|
### 2.5 基础设施
|
||||||
|
|
||||||
|
| 组件 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 自建文件服务器 | ✅ | Nginx + PHP,域名 files.test.ai.ireborn.com.cn |
|
||||||
|
| Coze 数据库 | ✅ | ci_interview_assessments, ci_interview_logs, ci_business_config |
|
||||||
|
|
||||||
|
## 三、技术架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 用户端 │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ 欢迎页面 │→│ 信息采集 │→│ 面试页面 │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ 文件上传 语音/文字模式 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓ ↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI 后端 │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ /init-interview │ /api/chat │ /api/room │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Coze 平台 │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Workflow A │ │ Workflow B │ │ Workflow C │ │
|
||||||
|
│ │ (初始化) │ │ (面试主流程) │ │ (数据查询) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ Coze Database │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、待解决问题
|
||||||
|
|
||||||
|
### 4.1 语音模式 session_id 传递
|
||||||
|
|
||||||
|
**问题**: 语音模式下,session_id 无法通过 /v1/audio/rooms API 传递到 Workflow B
|
||||||
|
|
||||||
|
**已尝试方案**:
|
||||||
|
1. ❌ `config.session_id` - 不支持
|
||||||
|
2. ❌ `parameters` 字段 - 不支持
|
||||||
|
3. ❌ `user_id` 字段 - 可设置但 Workflow 无法读取
|
||||||
|
4. ❌ Volcengine RTC SDK `sendRoomMessage` - 不是 Coze 信令通道
|
||||||
|
5. 🟡 `@coze/realtime-api` SDK `session.update` 事件 - 待验证
|
||||||
|
|
||||||
|
**建议方案**:
|
||||||
|
- 在 Workflow B 开始时通过数据库查询最近的 session_id
|
||||||
|
- 或使用 `@coze/realtime-api` 的 `session.update` 事件
|
||||||
|
|
||||||
|
### 4.2 面试阶段字段
|
||||||
|
|
||||||
|
**已实现**: current_stage 数值映射
|
||||||
|
- >= 50: 面试完成 (求职动机阶段)
|
||||||
|
- >= 40: 素质项评估
|
||||||
|
- >= 30: 销售观评估
|
||||||
|
- >= 20: 销售技能
|
||||||
|
- >= 10: 简历上传
|
||||||
|
|
||||||
|
## 五、数据库表结构
|
||||||
|
|
||||||
|
### ci_interview_assessments
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | String | 主键 (自动生成) |
|
||||||
|
| session_id | String | 会话 ID |
|
||||||
|
| candidate_name | String | 候选人姓名 |
|
||||||
|
| current_stage | String | 当前阶段 (10-50) |
|
||||||
|
| sales_skill_score | String | 销售技能分数 |
|
||||||
|
| sales_skill_report | String | 销售技能报告 |
|
||||||
|
| sales_concept_score | String | 销售观分数 |
|
||||||
|
| sales_concept_report | String | 销售观报告 |
|
||||||
|
| competency_score | String | 综合素质分数 |
|
||||||
|
| competency_report | String | 综合素质报告 |
|
||||||
|
| final_score_report | String | 最终评估报告 |
|
||||||
|
| resume_text | String | 简历文本 |
|
||||||
|
| risk_warning | String | 风险提示 |
|
||||||
|
|
||||||
|
### ci_interview_logs
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| log_id | String | 主键 (必填) |
|
||||||
|
| session_id | String | 会话 ID |
|
||||||
|
| stage | String | 面试阶段 |
|
||||||
|
| round | String | 轮次 |
|
||||||
|
| ai_question | String | AI 问题 |
|
||||||
|
| user_answer | String | 用户回答 |
|
||||||
|
| log_type | String | 日志类型 |
|
||||||
|
|
||||||
|
## 六、关键文件清单
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- `frontend/src/pages/interview/index.vue` - 欢迎页 + 信息采集
|
||||||
|
- `frontend/src/pages/interview/call.vue` - 面试页面 (语音/文字)
|
||||||
|
- `frontend/src/pages/admin/*.vue` - 管理后台页面
|
||||||
|
- `frontend/src/composables/useCozeRealtime.ts` - Coze Realtime SDK 封装
|
||||||
|
- `frontend/src/api/candidate.ts` - API 调用封装
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- `backend/main.py` - FastAPI 入口
|
||||||
|
- `backend/app/services/coze_service.py` - Coze API 服务
|
||||||
|
- `backend/app/routers/init.py` - 面试初始化 API
|
||||||
|
- `backend/app/routers/chat.py` - 文字聊天 API
|
||||||
|
- `backend/app/routers/room.py` - 语音房间 API
|
||||||
|
- `backend/app/routers/admin.py` - 管理后台 API
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
- `backend/.env` - 环境变量 (PAT_TOKEN, BOT_ID, FILE_SERVER_TOKEN 等)
|
||||||
|
- `docs/upload.php` - 文件上传 PHP 脚本
|
||||||
|
- `docs/nginx-files-server.conf` - Nginx 配置
|
||||||
|
|
||||||
|
## 七、访问地址
|
||||||
|
|
||||||
|
| 服务 | 地址 |
|
||||||
|
|------|------|
|
||||||
|
| 用户端 | http://localhost:5173 |
|
||||||
|
| 管理后台 | http://localhost:5173/admin |
|
||||||
|
| 后端 API | http://localhost:8000 |
|
||||||
|
| 文件服务器 | https://files.test.ai.ireborn.com.cn |
|
||||||
194
docs/项目进度总结-20260121.md
Normal file
194
docs/项目进度总结-20260121.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# AI 语音面试系统 - 项目进度总结
|
||||||
|
|
||||||
|
**更新日期**: 2026-01-21
|
||||||
|
**项目代号**: 011-ai-interview-2601
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、已完成功能
|
||||||
|
|
||||||
|
### 1. 基础架构 ✅
|
||||||
|
- [x] 前端项目初始化 (Vue 3 + TypeScript + Vite + Element Plus)
|
||||||
|
- [x] 后端项目初始化 (FastAPI + Python)
|
||||||
|
- [x] Coze API 集成 (PAT Token 认证)
|
||||||
|
- [x] 文件服务器搭建 (Nginx + PHP 上传脚本)
|
||||||
|
|
||||||
|
### 2. 工作流 A - 面试初始化 ✅
|
||||||
|
- [x] 接收姓名和简历 PDF
|
||||||
|
- [x] 上传简历到自建文件服务器 (`files.test.ai.ireborn.com.cn`)
|
||||||
|
- [x] 调用 Coze 工作流解析简历
|
||||||
|
- [x] 生成 `session_id` 并写入 `ci_interview_assessments` 表
|
||||||
|
- [x] 返回 `session_id` 给前端
|
||||||
|
|
||||||
|
### 3. 工作流 B - 面试对话 ⚠️ (部分完成)
|
||||||
|
- [x] 调用 Coze `/v3/chat` API 进行对话
|
||||||
|
- [x] 支持文字面试模式
|
||||||
|
- [x] 支持语音面试模式 (RTC 房间创建)
|
||||||
|
- [ ] **未完成**: 多轮对话状态维持
|
||||||
|
- [ ] **未完成**: 对话日志写入 `ci_interview_logs` 表
|
||||||
|
|
||||||
|
### 4. 工作流 C1 - 数据查询 ✅
|
||||||
|
- [x] 支持查询 `ci_interview_assessments` 表
|
||||||
|
- [x] 支持查询 `ci_interview_logs` 表
|
||||||
|
- [x] 支持查询 `ci_business_config` 表
|
||||||
|
- [x] 通过 JSON 输入指定表名和 SQL
|
||||||
|
|
||||||
|
### 5. 后台管理页面 ✅
|
||||||
|
- [x] 登录页面 (`/admin/login`)
|
||||||
|
- [x] 面试列表页面 (`/admin/interviews`)
|
||||||
|
- [x] 面试详情页面 (`/admin/interviews/:id`)
|
||||||
|
- [x] 基本信息展示
|
||||||
|
- [x] 维度评分展示
|
||||||
|
- [x] 评估报告展示
|
||||||
|
- [x] 对话记录展示 (数据依赖工作流 B)
|
||||||
|
- [x] 配置管理页面 (`/admin/configs`)
|
||||||
|
|
||||||
|
### 6. 文件服务 ✅
|
||||||
|
- [x] 自建文件服务器 (`files.test.ai.ireborn.com.cn`)
|
||||||
|
- [x] PHP 上传接口 (Token 认证)
|
||||||
|
- [x] Nginx 静态文件服务
|
||||||
|
- [x] CORS 支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心问题
|
||||||
|
|
||||||
|
### 问题:工作流 B 多轮对话状态无法维持
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- 每次调用 `/v3/chat` API,Coze 返回新的 `conversation_id`
|
||||||
|
- 即使传入上一次的 `conversation_id`,仍然返回新值
|
||||||
|
- 导致工作流 B 每次都从头执行,重复显示欢迎语
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
1. 面试无法正常进行多轮问答
|
||||||
|
2. 对话日志无法写入 `ci_interview_logs` 表
|
||||||
|
3. 后台管理页面无法显示对话记录
|
||||||
|
|
||||||
|
**已尝试的方案**:
|
||||||
|
1. ❌ 传入 `conversation_id` 参数 - Coze 仍返回新值
|
||||||
|
2. ❌ 使用 `/v1/workflows/chat` 接口 - 接口不存在或参数格式不对
|
||||||
|
3. ⏳ 通过 `session_id` 在工作流内部管理状态 - 需要修改工作流 B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、解决方案建议
|
||||||
|
|
||||||
|
### 方案:工作流 B 内部状态管理
|
||||||
|
|
||||||
|
修改 Coze 工作流 B,不依赖 Coze 的 conversation 机制,改为:
|
||||||
|
|
||||||
|
1. **开始节点**: 接收 `session_id` 和用户消息
|
||||||
|
2. **查询状态**: 从 `ci_interview_logs` 表查询该 `session_id` 的历史对话数量
|
||||||
|
3. **条件分支**:
|
||||||
|
- 如果历史为 0:显示欢迎语,更新状态
|
||||||
|
- 如果历史 > 0:直接进入下一轮提问
|
||||||
|
4. **写入日志**: 每次对话后,将 AI 问题和用户回答写入 `ci_interview_logs`
|
||||||
|
5. **结束判断**: 当对话轮次达到设定值时,生成评估报告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、技术栈
|
||||||
|
|
||||||
|
| 模块 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | Vue 3 + TypeScript + Vite + Element Plus + Tailwind CSS |
|
||||||
|
| 后端 | Python + FastAPI + Pydantic + httpx |
|
||||||
|
| AI 平台 | Coze (扣子) |
|
||||||
|
| 文件服务 | Nginx + PHP |
|
||||||
|
| 数据库 | Coze 内置数据库 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、API 端点
|
||||||
|
|
||||||
|
### 后端 API
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/init-interview` | POST | 初始化面试(上传简历、生成 session_id) |
|
||||||
|
| `/api/chat` | POST | 文字面试对话 |
|
||||||
|
| `/api/rooms` | POST | 创建语音面试房间 |
|
||||||
|
| `/api/admin/login` | POST | 管理员登录 |
|
||||||
|
| `/api/admin/interviews` | GET | 面试列表 |
|
||||||
|
| `/api/admin/interviews/{id}` | GET | 面试详情 |
|
||||||
|
| `/api/admin/interviews/{id}/logs` | GET | 对话记录 |
|
||||||
|
|
||||||
|
### Coze 工作流
|
||||||
|
|
||||||
|
| 工作流 | ID | 说明 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| 工作流 A | 7597357422713798710 | 面试初始化(简历解析) |
|
||||||
|
| 工作流 B | 7595077233002840079 | 面试对话(Chatflow) |
|
||||||
|
| 工作流 C1 | 7597376294612107318 | 数据查询 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、数据库表
|
||||||
|
|
||||||
|
| 表名 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| ci_interview_assessments | 面试评估记录 |
|
||||||
|
| ci_interview_logs | 面试对话日志 |
|
||||||
|
| ci_business_config | 业务配置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、下一步工作
|
||||||
|
|
||||||
|
1. **修改工作流 B** - 实现内部状态管理,不依赖 conversation_id
|
||||||
|
2. **测试多轮对话** - 验证对话日志正确写入数据库
|
||||||
|
3. **完善语音面试** - 测试 RTC 语音对话功能
|
||||||
|
4. **优化后台页面** - 改进布局和用户体验
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
011-ai-interview-2601/
|
||||||
|
├── frontend/ # 前端项目
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API 请求
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── interview/ # 面试页面
|
||||||
|
│ │ │ └── admin/ # 后台管理
|
||||||
|
│ │ ├── composables/ # 组合式函数 (useRTC)
|
||||||
|
│ │ └── router/ # 路由配置
|
||||||
|
│ └── package.json
|
||||||
|
├── backend/ # 后端项目
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── routers/ # API 路由
|
||||||
|
│ │ ├── services/ # 服务层 (coze_service)
|
||||||
|
│ │ └── schemas.py # 数据模型
|
||||||
|
│ ├── main.py
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── docs/ # 文档
|
||||||
|
│ ├── upload.php # PHP 上传脚本
|
||||||
|
│ ├── nginx-files-server.conf
|
||||||
|
│ └── 项目进度总结-20260121.md
|
||||||
|
└── coze-workflows/ # Coze 工作流配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、配置信息
|
||||||
|
|
||||||
|
### 环境变量 (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
COZE_PAT_TOKEN=pat_xxx
|
||||||
|
COZE_BOT_ID=7595113005181386792
|
||||||
|
FILE_SERVER_TOKEN=ai_interview_2026_secret
|
||||||
|
TUNNEL_URL=http://files.test.ai.ireborn.com.cn
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件服务器
|
||||||
|
|
||||||
|
- 域名: `files.test.ai.ireborn.com.cn`
|
||||||
|
- 上传接口: `/upload.php`
|
||||||
|
- 文件目录: `/resumes/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**: 项目基础架构已完成,核心问题是工作流 B 的多轮对话状态维持。需要在 Coze 工作流 B 中实现基于 session_id 的状态管理,才能正确记录对话日志并在后台展示。
|
||||||
BIN
frontend/dist.tar.gz
Normal file
BIN
frontend/dist.tar.gz
Normal file
Binary file not shown.
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI 面试助手</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-interview-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"element-plus": "^2.5.6",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@volcengine/rtc": "^4.58.1",
|
||||||
|
"@coze/realtime-api": "^1.0.0",
|
||||||
|
"@coze/api": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"@types/node": "^20.11.24",
|
||||||
|
"typescript": "^5.4.2",
|
||||||
|
"vite": "^5.1.5",
|
||||||
|
"vue-tsc": "^2.0.6",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||||
|
"@typescript-eslint/parser": "^7.1.0",
|
||||||
|
"eslint-plugin-vue": "^9.22.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3195
frontend/pnpm-lock.yaml
generated
Normal file
3195
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
10
frontend/src/App.vue
Normal file
10
frontend/src/App.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
178
frontend/src/api/candidate.ts
Normal file
178
frontend/src/api/candidate.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
export interface InitInterviewResponse {
|
||||||
|
sessionId: string
|
||||||
|
name: string
|
||||||
|
debugUrl?: string
|
||||||
|
workflowResponse?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitCandidateResponse {
|
||||||
|
sessionId: string
|
||||||
|
fileId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoomRequest {
|
||||||
|
sessionId?: string
|
||||||
|
fileId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoomResponse {
|
||||||
|
roomId: string
|
||||||
|
token: string
|
||||||
|
appId: string
|
||||||
|
userId: string
|
||||||
|
sessionId?: string // 后端生成的会话ID
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRequest {
|
||||||
|
sessionId: string
|
||||||
|
message: string
|
||||||
|
conversationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
reply: string
|
||||||
|
conversationId: string
|
||||||
|
debugInfo?: {
|
||||||
|
status_history?: Array<{
|
||||||
|
iteration: number
|
||||||
|
status: string
|
||||||
|
required_action?: any
|
||||||
|
}>
|
||||||
|
messages?: Array<{
|
||||||
|
role: string
|
||||||
|
type: string
|
||||||
|
content: string
|
||||||
|
}>
|
||||||
|
raw_responses?: any[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Candidate {
|
||||||
|
sessionId: string
|
||||||
|
name: string
|
||||||
|
status: 'pending' | 'ongoing' | 'completed'
|
||||||
|
score?: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateDetail extends Candidate {
|
||||||
|
resume: string
|
||||||
|
currentStage: number
|
||||||
|
scores?: {
|
||||||
|
salesSkill: number
|
||||||
|
salesMindset: number
|
||||||
|
quality: number
|
||||||
|
motivation: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
analysis?: string
|
||||||
|
interviewLog?: string
|
||||||
|
completedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateListResponse {
|
||||||
|
list: Candidate[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateListParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
status?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 方法
|
||||||
|
export const candidateApi = {
|
||||||
|
/**
|
||||||
|
* 初始化面试(工作流 A)
|
||||||
|
* 上传简历 + 执行初始化工作流 → 返回 sessionId
|
||||||
|
*/
|
||||||
|
initInterview(name: string, resumeFile: File) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('name', name)
|
||||||
|
formData.append('file', resumeFile)
|
||||||
|
return request.upload<InitInterviewResponse>('/init-interview', formData)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传简历到 Coze
|
||||||
|
*/
|
||||||
|
uploadResume(file: File) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return request.upload<{ fileId: string }>('/upload', formData)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交候选人信息(上传简历)- 旧方法
|
||||||
|
*/
|
||||||
|
submit(name: string, resumeFile: File) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('name', name)
|
||||||
|
formData.append('resume', resumeFile)
|
||||||
|
return request.upload<SubmitCandidateResponse>('/candidates', formData)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建语音房间
|
||||||
|
*/
|
||||||
|
createRoom(data: CreateRoomRequest) {
|
||||||
|
return request.post<CreateRoomResponse>('/rooms', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束面试
|
||||||
|
*/
|
||||||
|
endInterview(sessionId: string) {
|
||||||
|
return request.post<{ success: boolean }>(`/interviews/${sessionId}/end`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取候选人列表
|
||||||
|
*/
|
||||||
|
getList(params: CandidateListParams) {
|
||||||
|
return request.get<CandidateListResponse>('/candidates', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取候选人详情
|
||||||
|
*/
|
||||||
|
getDetail(sessionId: string) {
|
||||||
|
return request.get<CandidateDetail>(`/candidates/${sessionId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出 PDF 报告
|
||||||
|
*/
|
||||||
|
exportPdf(sessionId: string) {
|
||||||
|
return `/api/candidates/${sessionId}/export`
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本对话(模拟语音)
|
||||||
|
*/
|
||||||
|
chat(data: ChatRequest) {
|
||||||
|
return request.post<ChatResponse>('/chat', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Coze Realtime SDK 配置
|
||||||
|
* 用于直接连接 Coze 语音服务
|
||||||
|
*/
|
||||||
|
getCozeConfig() {
|
||||||
|
return request.get<{
|
||||||
|
accessToken: string
|
||||||
|
botId: string
|
||||||
|
voiceId: string
|
||||||
|
connectorId: string
|
||||||
|
}>('/coze-config')
|
||||||
|
},
|
||||||
|
}
|
||||||
11
frontend/src/api/index.ts
Normal file
11
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { request } from './request'
|
||||||
|
export { candidateApi } from './candidate'
|
||||||
|
export type {
|
||||||
|
SubmitCandidateResponse,
|
||||||
|
CreateRoomRequest,
|
||||||
|
CreateRoomResponse,
|
||||||
|
Candidate,
|
||||||
|
CandidateDetail,
|
||||||
|
CandidateListResponse,
|
||||||
|
CandidateListParams,
|
||||||
|
} from './candidate'
|
||||||
86
frontend/src/api/request.ts
Normal file
86
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
// API 响应类型
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const instance: AxiosInstance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||||
|
timeout: 120000, // 120秒,Coze API 可能需要较长时间
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
instance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// 可以在这里添加 token
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse<ApiResponse>) => {
|
||||||
|
const { data } = response
|
||||||
|
|
||||||
|
// 业务错误处理
|
||||||
|
if (data.code !== 0) {
|
||||||
|
ElMessage.error(data.message || '请求失败')
|
||||||
|
return Promise.reject(new Error(data.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// HTTP 错误处理
|
||||||
|
const message = error.response?.data?.message || error.message || '网络错误'
|
||||||
|
ElMessage.error(message)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 封装请求方法
|
||||||
|
export const request = {
|
||||||
|
get<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return instance.get(url, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return instance.post(url, data, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return instance.put(url, data, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return instance.delete(url, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
upload<T>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return instance.post(url, formData, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
}).then((res) => res.data)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default instance
|
||||||
1
frontend/src/composables/index.ts
Normal file
1
frontend/src/composables/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useRTC } from './useRTC'
|
||||||
749
frontend/src/composables/useCozeRealtime.ts
Normal file
749
frontend/src/composables/useCozeRealtime.ts
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
/**
|
||||||
|
* Coze Realtime 语音面试 Hook
|
||||||
|
*
|
||||||
|
* 基于 Coze 官方 SDK (@coze/realtime-api) 实现语音面试
|
||||||
|
*
|
||||||
|
* 架构说明:
|
||||||
|
* 1. 使用 @coze/realtime-api SDK 直接连接
|
||||||
|
* 2. 在 bot.join 后通过 sendMessage 发送 session.update 信令传递 session_id
|
||||||
|
*
|
||||||
|
* 参考文档:
|
||||||
|
* - https://www.coze.cn/open/docs/dev_how_to_guides/Realtime_web
|
||||||
|
* - https://www.coze.cn/open/docs/developer_guides/signaling_uplink_event
|
||||||
|
* - https://github.com/coze-dev/coze-js/blob/main/examples/realtime-quickstart-react/src/App.tsx
|
||||||
|
*/
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { RTCConnectionState } from '@/types'
|
||||||
|
|
||||||
|
// Coze Realtime 连接参数
|
||||||
|
interface CozeRealtimeParams {
|
||||||
|
accessToken: string // PAT Token
|
||||||
|
botId: string // 智能体 ID
|
||||||
|
sessionId: string // 面试会话 ID(关键:需要传递给工作流)
|
||||||
|
voiceId?: string // 音色 ID(可选)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频配置(根据 Coze 官方文档 session.update 事件参数)
|
||||||
|
const AUDIO_CONFIG = {
|
||||||
|
// VAD(语音活动检测)配置
|
||||||
|
// 注意:silence_duration_ms 取值范围 200~2000,默认 500
|
||||||
|
vad: {
|
||||||
|
silenceDurationMs: 800, // 静音持续 0.8 秒判定为说话结束(降低延迟)
|
||||||
|
prefixPaddingMs: 300, // 前置填充 300ms(防止开头被截断)
|
||||||
|
},
|
||||||
|
// 打断配置
|
||||||
|
allowVoiceInterrupt: false, // 禁止语音打断 AI
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地 VAD 配置
|
||||||
|
const LOCAL_VAD_CONFIG = {
|
||||||
|
enabled: true, // 启用本地 VAD
|
||||||
|
volumeThreshold: 0.15, // 音量阈值(0-1),15% 过滤环境噪音和回声
|
||||||
|
silenceTimeout: 800, // 静音超时(毫秒),持续静音后触发 commit
|
||||||
|
minSpeechDuration: 300, // 最小说话时长(毫秒),防止误触发
|
||||||
|
checkInterval: 50, // 检测间隔(毫秒)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 信令事件名称(来自 @coze/realtime-api EventNames)
|
||||||
|
// 注意:实际使用时从 SDK 动态导入,这里仅作为类型参考
|
||||||
|
const _EventNamesRef = {
|
||||||
|
ALL: 'realtime.event',
|
||||||
|
ALL_CLIENT: 'client.*',
|
||||||
|
ALL_SERVER: 'server.*',
|
||||||
|
CONNECTED: 'client.connected',
|
||||||
|
INTERRUPTED: 'client.interrupted',
|
||||||
|
DISCONNECTED: 'client.disconnected',
|
||||||
|
AUDIO_UNMUTED: 'client.audio.unmuted',
|
||||||
|
AUDIO_MUTED: 'client.audio.muted',
|
||||||
|
ERROR: 'client.error',
|
||||||
|
}
|
||||||
|
void _EventNamesRef // 避免 unused 警告
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coze Realtime Hook
|
||||||
|
* 使用 Coze 官方 SDK 进行语音面试
|
||||||
|
*/
|
||||||
|
export function useCozeRealtime() {
|
||||||
|
const connectionState = ref<RTCConnectionState>('disconnected')
|
||||||
|
const isMuted = ref(false)
|
||||||
|
const sessionId = ref('')
|
||||||
|
const isSessionUpdateSent = ref(false)
|
||||||
|
const lastError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 通讯时间线(用于调试显示)
|
||||||
|
interface TimelineEvent {
|
||||||
|
time: string // HH:MM:SS.mmm 格式
|
||||||
|
type: 'send' | 'receive' | 'audio' | 'system' | 'error'
|
||||||
|
event: string // 事件名
|
||||||
|
detail?: string // 详情
|
||||||
|
}
|
||||||
|
const timeline = ref<TimelineEvent[]>([])
|
||||||
|
|
||||||
|
// 添加时间线事件
|
||||||
|
function addTimelineEvent(type: TimelineEvent['type'], event: string, detail?: string) {
|
||||||
|
const now = new Date()
|
||||||
|
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}.${String(now.getMilliseconds()).padStart(3, '0')}`
|
||||||
|
timeline.value.push({ time, type, event, detail })
|
||||||
|
// 保留最近 50 条
|
||||||
|
if (timeline.value.length > 50) {
|
||||||
|
timeline.value.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试信息
|
||||||
|
const debugInfo = ref<{
|
||||||
|
rtcConnected: boolean
|
||||||
|
sessionUpdateSent: boolean
|
||||||
|
sessionId: string
|
||||||
|
botJoined: boolean
|
||||||
|
eventsSent: string[]
|
||||||
|
eventsReceived: string[]
|
||||||
|
errors: string[]
|
||||||
|
}>({
|
||||||
|
rtcConnected: false,
|
||||||
|
sessionUpdateSent: false,
|
||||||
|
sessionId: '',
|
||||||
|
botJoined: false,
|
||||||
|
eventsSent: [],
|
||||||
|
eventsReceived: [],
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let client: any = null
|
||||||
|
|
||||||
|
// ============ 本地 VAD 相关 ============
|
||||||
|
const isSpeaking = ref(false) // 用户是否正在说话
|
||||||
|
const isAISpeaking = ref(false) // AI 是否正在说话(用于暂停收音)
|
||||||
|
const currentVolume = ref(0) // 当前音量(0-1)
|
||||||
|
let audioContext: AudioContext | null = null
|
||||||
|
let analyser: AnalyserNode | null = null
|
||||||
|
let mediaStream: MediaStream | null = null
|
||||||
|
let vadCheckInterval: number | null = null
|
||||||
|
let silenceStartTime: number | null = null
|
||||||
|
let speechStartTime: number | null = null
|
||||||
|
let hasCommittedThisTurn = false // 本轮是否已提交
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动本地 VAD 监测
|
||||||
|
*/
|
||||||
|
async function startLocalVAD() {
|
||||||
|
if (!LOCAL_VAD_CONFIG.enabled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取麦克风音频流(开启回声消除和噪音抑制)
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true, // 回声消除(消除扬声器声音)
|
||||||
|
noiseSuppression: true, // 噪音抑制
|
||||||
|
autoGainControl: true, // 自动增益控制
|
||||||
|
}
|
||||||
|
})
|
||||||
|
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
|
analyser = audioContext.createAnalyser()
|
||||||
|
analyser.fftSize = 512
|
||||||
|
analyser.smoothingTimeConstant = 0.4 // 提高平滑度,减少抖动
|
||||||
|
|
||||||
|
const source = audioContext.createMediaStreamSource(mediaStream)
|
||||||
|
source.connect(analyser)
|
||||||
|
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
|
||||||
|
// 定期检测音量
|
||||||
|
vadCheckInterval = window.setInterval(() => {
|
||||||
|
if (!analyser || isMuted.value) return
|
||||||
|
|
||||||
|
analyser.getByteFrequencyData(dataArray)
|
||||||
|
|
||||||
|
// 计算平均音量(归一化到 0-1)
|
||||||
|
const sum = dataArray.reduce((a, b) => a + b, 0)
|
||||||
|
const avg = sum / dataArray.length / 255
|
||||||
|
currentVolume.value = avg
|
||||||
|
|
||||||
|
// 注意:不再基于 isAISpeaking 暂停收音
|
||||||
|
// 依赖浏览器的回声消除 + 较高的音量阈值来过滤 AI 声音
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (avg > LOCAL_VAD_CONFIG.volumeThreshold) {
|
||||||
|
// 检测到声音
|
||||||
|
if (!isSpeaking.value) {
|
||||||
|
isSpeaking.value = true
|
||||||
|
speechStartTime = now
|
||||||
|
silenceStartTime = null
|
||||||
|
hasCommittedThisTurn = false
|
||||||
|
addTimelineEvent('audio', '🎤 检测到说话', `音量: ${(avg * 100).toFixed(1)}%`)
|
||||||
|
console.log(`🎤 [LocalVAD] 检测到说话, 音量: ${(avg * 100).toFixed(1)}%`)
|
||||||
|
}
|
||||||
|
silenceStartTime = null
|
||||||
|
} else if (isSpeaking.value) {
|
||||||
|
// 正在说话但当前静音
|
||||||
|
if (!silenceStartTime) {
|
||||||
|
silenceStartTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
const silenceDuration = now - silenceStartTime
|
||||||
|
const speechDuration = speechStartTime ? now - speechStartTime : 0
|
||||||
|
|
||||||
|
// 检查是否满足提交条件
|
||||||
|
if (silenceDuration >= LOCAL_VAD_CONFIG.silenceTimeout &&
|
||||||
|
speechDuration >= LOCAL_VAD_CONFIG.minSpeechDuration &&
|
||||||
|
!hasCommittedThisTurn) {
|
||||||
|
// 静音超时,触发提交
|
||||||
|
isSpeaking.value = false
|
||||||
|
hasCommittedThisTurn = true
|
||||||
|
addTimelineEvent('audio', '🔇 说话结束', `静音 ${silenceDuration}ms`)
|
||||||
|
console.log(`🔇 [LocalVAD] 说话结束, 静音 ${silenceDuration}ms, 自动提交`)
|
||||||
|
|
||||||
|
// 自动发送 commit
|
||||||
|
commitAudioInputInternal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, LOCAL_VAD_CONFIG.checkInterval)
|
||||||
|
|
||||||
|
addTimelineEvent('system', '🎙️ 本地VAD启动', `阈值: ${LOCAL_VAD_CONFIG.volumeThreshold}, 静音: ${LOCAL_VAD_CONFIG.silenceTimeout}ms`)
|
||||||
|
console.log('✅ [LocalVAD] 本地 VAD 已启动')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [LocalVAD] 启动失败:', error)
|
||||||
|
addTimelineEvent('error', '❌ VAD启动失败', String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止本地 VAD 监测
|
||||||
|
*/
|
||||||
|
function stopLocalVAD() {
|
||||||
|
if (vadCheckInterval) {
|
||||||
|
clearInterval(vadCheckInterval)
|
||||||
|
vadCheckInterval = null
|
||||||
|
}
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop())
|
||||||
|
mediaStream = null
|
||||||
|
}
|
||||||
|
if (audioContext) {
|
||||||
|
audioContext.close()
|
||||||
|
audioContext = null
|
||||||
|
}
|
||||||
|
analyser = null
|
||||||
|
isSpeaking.value = false
|
||||||
|
currentVolume.value = 0
|
||||||
|
console.log('🛑 [LocalVAD] 已停止')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部提交函数(供 VAD 调用)
|
||||||
|
*/
|
||||||
|
function commitAudioInputInternal() {
|
||||||
|
if (!client) return false
|
||||||
|
|
||||||
|
const commitEvent = {
|
||||||
|
id: `evt_${Date.now()}_commit`,
|
||||||
|
event_type: 'input_audio_buffer.commit',
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.sendMessage(commitEvent)
|
||||||
|
addTimelineEvent('send', '📤 语音已提交', '本地VAD触发')
|
||||||
|
console.log('📤 [LocalVAD] 已发送 commit')
|
||||||
|
debugInfo.value.eventsSent.push('input_audio_buffer.commit (local_vad)')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [LocalVAD] commit 失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接到 Coze 语音房间
|
||||||
|
*
|
||||||
|
* 使用 @coze/realtime-api SDK 连接
|
||||||
|
*/
|
||||||
|
async function connect(params: CozeRealtimeParams) {
|
||||||
|
try {
|
||||||
|
connectionState.value = 'connecting'
|
||||||
|
lastError.value = null
|
||||||
|
sessionId.value = params.sessionId
|
||||||
|
|
||||||
|
// 更新调试信息
|
||||||
|
debugInfo.value.sessionId = params.sessionId
|
||||||
|
debugInfo.value.eventsSent = []
|
||||||
|
debugInfo.value.eventsReceived = []
|
||||||
|
debugInfo.value.errors = []
|
||||||
|
|
||||||
|
// 清空时间线
|
||||||
|
timeline.value = []
|
||||||
|
addTimelineEvent('system', '🚀 开始连接', `session: ${params.sessionId.slice(-8)}`)
|
||||||
|
|
||||||
|
console.log('=== Coze Realtime: Starting connection ===')
|
||||||
|
console.log('Session ID:', params.sessionId)
|
||||||
|
console.log('Bot ID:', params.botId)
|
||||||
|
|
||||||
|
// 1. 动态导入 Coze Realtime SDK
|
||||||
|
console.log('Step 1: Loading @coze/realtime-api SDK...')
|
||||||
|
const { RealtimeClient, RealtimeUtils, EventNames: SDKEventNames } = await import('@coze/realtime-api')
|
||||||
|
|
||||||
|
// 2. 检查设备权限
|
||||||
|
console.log('Step 2: Checking device permission...')
|
||||||
|
const permission = await RealtimeUtils.checkDevicePermission()
|
||||||
|
if (!permission.audio) {
|
||||||
|
throw new Error('需要麦克风访问权限。请在浏览器地址栏左侧的锁图标中授权。')
|
||||||
|
}
|
||||||
|
console.log('✅ Microphone permission granted')
|
||||||
|
|
||||||
|
// 3. 恢复音频上下文(解决浏览器自动播放限制)
|
||||||
|
console.log('Step 3: Resuming AudioContext for autoplay...')
|
||||||
|
try {
|
||||||
|
// 创建并恢复音频上下文,确保音频可以播放
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
|
if (audioContext.state === 'suspended') {
|
||||||
|
await audioContext.resume()
|
||||||
|
console.log('✅ AudioContext resumed')
|
||||||
|
}
|
||||||
|
} catch (audioErr) {
|
||||||
|
console.warn('AudioContext resume failed:', audioErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 初始化客户端(含音频配置)
|
||||||
|
console.log('Step 4: Initializing RealtimeClient...')
|
||||||
|
console.log('Audio config:', AUDIO_CONFIG)
|
||||||
|
client = new RealtimeClient({
|
||||||
|
accessToken: params.accessToken,
|
||||||
|
botId: params.botId,
|
||||||
|
connectorId: '1024', // 固定值
|
||||||
|
voiceId: params.voiceId,
|
||||||
|
allowPersonalAccessTokenInBrowser: true,
|
||||||
|
debug: true,
|
||||||
|
// 🔊 音频配置
|
||||||
|
audioMutedDefault: false, // 默认不静音,启用音频播放
|
||||||
|
suppressStationaryNoise: true, // 抑制静态噪音
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. 配置事件监听
|
||||||
|
console.log('Step 5: Setting up event listeners...')
|
||||||
|
setupEventListeners(SDKEventNames, params.sessionId)
|
||||||
|
|
||||||
|
// 6. 建立连接
|
||||||
|
console.log('Step 6: Connecting to Coze...')
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
// 7. 确保音频输出启用
|
||||||
|
console.log('Step 7: Ensuring audio output is enabled...')
|
||||||
|
try {
|
||||||
|
// 尝试播放一个静音音频来解锁音频播放
|
||||||
|
const silentAudio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA')
|
||||||
|
silentAudio.volume = 0.01
|
||||||
|
await silentAudio.play().catch(() => {})
|
||||||
|
console.log('✅ Audio output unlocked')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Audio unlock attempt:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
debugInfo.value.rtcConnected = true
|
||||||
|
connectionState.value = 'connected'
|
||||||
|
console.log('=== Coze Realtime: Connected successfully ===')
|
||||||
|
|
||||||
|
// 8. 确保远程音频播放已启用
|
||||||
|
console.log('Step 8: Enabling remote audio playback...')
|
||||||
|
try {
|
||||||
|
// 尝试启用远程音频播放(AI 的声音)
|
||||||
|
if (typeof client.setRemoteAudioEnable === 'function') {
|
||||||
|
await client.setRemoteAudioEnable(true)
|
||||||
|
console.log('✅ Remote audio playback enabled via setRemoteAudioEnable')
|
||||||
|
}
|
||||||
|
// 尝试通过 RTC 引擎设置
|
||||||
|
const rtcEngine = client.getRtcEngine?.()
|
||||||
|
if (rtcEngine) {
|
||||||
|
console.log('📻 RTC Engine available, checking audio settings...')
|
||||||
|
if (typeof rtcEngine.setRemoteAudioPlaybackVolume === 'function') {
|
||||||
|
rtcEngine.setRemoteAudioPlaybackVolume('*', 100)
|
||||||
|
console.log('✅ Remote audio volume set to 100')
|
||||||
|
}
|
||||||
|
if (typeof rtcEngine.subscribeAllRemoteAudio === 'function') {
|
||||||
|
rtcEngine.subscribeAllRemoteAudio()
|
||||||
|
console.log('✅ Subscribed to all remote audio')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Remote audio setup:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 启动本地 VAD(更快响应)
|
||||||
|
console.log('Step 9: Starting local VAD...')
|
||||||
|
await startLocalVAD()
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error('Coze Realtime connect error:', error)
|
||||||
|
lastError.value = errorMessage
|
||||||
|
debugInfo.value.errors.push(errorMessage)
|
||||||
|
connectionState.value = 'failed'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置事件监听器
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function setupEventListeners(SDKEventNames: any, sid: string) {
|
||||||
|
if (!client) return
|
||||||
|
|
||||||
|
console.log('🎧 Setting up event listeners...')
|
||||||
|
console.log('Available EventNames:', Object.keys(SDKEventNames))
|
||||||
|
|
||||||
|
addTimelineEvent('system', '🔌 连接建立', '开始监听事件')
|
||||||
|
|
||||||
|
// 监听所有服务端事件
|
||||||
|
client.on(SDKEventNames.ALL_SERVER, (eventName: string, event: unknown) => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString()
|
||||||
|
console.log(`📩 [${timestamp}] Server event: ${eventName}`, event)
|
||||||
|
debugInfo.value.eventsReceived.push(`${eventName} @ ${timestamp}`)
|
||||||
|
|
||||||
|
// 当 Bot 加入房间后,发送 session.update 事件
|
||||||
|
if (eventName === 'server.bot.join') {
|
||||||
|
console.log('🤖 Bot joined! Sending session.update...')
|
||||||
|
debugInfo.value.botJoined = true
|
||||||
|
addTimelineEvent('receive', '🤖 Bot 加入', 'server.bot.join')
|
||||||
|
sendSessionUpdate(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户开始说话
|
||||||
|
if (eventName === 'server.input_audio_buffer.speech_started') {
|
||||||
|
addTimelineEvent('audio', '🎤 检测到说话', '用户开始说话')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户停止说话(VAD 触发)
|
||||||
|
if (eventName === 'server.input_audio_buffer.speech_stopped') {
|
||||||
|
addTimelineEvent('audio', '🔇 说话结束', 'VAD 检测到静音')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音提交到服务器
|
||||||
|
if (eventName === 'server.input_audio_buffer.committed') {
|
||||||
|
addTimelineEvent('send', '📤 语音已提交', '等待 AI 处理')
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 开始处理/思考
|
||||||
|
if (eventName === 'server.conversation.item.created') {
|
||||||
|
addTimelineEvent('receive', '🧠 AI 开始处理', 'conversation.item.created')
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 开始回复(流式)
|
||||||
|
if (eventName === 'server.conversation.message.delta') {
|
||||||
|
// 只记录第一次 delta(避免太多条目)
|
||||||
|
const lastEvent = timeline.value[timeline.value.length - 1]
|
||||||
|
if (!lastEvent || lastEvent.event !== '💬 AI 回复中') {
|
||||||
|
isAISpeaking.value = true // 🔇 AI 开始说话,暂停用户收音
|
||||||
|
addTimelineEvent('receive', '💬 AI 回复中', '开始接收语音流(暂停收音)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 回复完成
|
||||||
|
if (eventName === 'server.conversation.message.completed') {
|
||||||
|
addTimelineEvent('receive', '✅ AI 回复完成', 'message.completed')
|
||||||
|
// 延迟后重置 AI 说话状态(用于 UI 显示)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isAISpeaking.value) {
|
||||||
|
isAISpeaking.value = false
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 开始说话(用于 UI 显示,不影响收音)
|
||||||
|
if (eventName === 'server.audio.agent.speech_started') {
|
||||||
|
isAISpeaking.value = true
|
||||||
|
addTimelineEvent('receive', '🔊 AI 开始说话', eventName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 说话结束(用于 UI 显示)
|
||||||
|
if (eventName === 'server.audio.agent.speech_stopped') {
|
||||||
|
isAISpeaking.value = false
|
||||||
|
addTimelineEvent('receive', '🔈 AI 说话结束', eventName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回合结束
|
||||||
|
if (eventName === 'server.response.done') {
|
||||||
|
isAISpeaking.value = false
|
||||||
|
addTimelineEvent('receive', '🏁 回合结束', 'response.done')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录所有包含 audio 的事件(用于调试)
|
||||||
|
if (eventName.toLowerCase().includes('audio')) {
|
||||||
|
console.log(`🔊 [Audio Event] ${eventName}:`, event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听客户端事件
|
||||||
|
client.on(SDKEventNames.CONNECTED, () => {
|
||||||
|
console.log('✅ [Client] Connected to Coze Realtime')
|
||||||
|
debugInfo.value.eventsReceived.push('client.connected')
|
||||||
|
addTimelineEvent('system', '✅ 已连接', 'RTC 连接成功')
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on(SDKEventNames.DISCONNECTED, () => {
|
||||||
|
console.log('❌ [Client] Disconnected from Coze Realtime')
|
||||||
|
connectionState.value = 'disconnected'
|
||||||
|
debugInfo.value.eventsReceived.push('client.disconnected')
|
||||||
|
addTimelineEvent('system', '❌ 已断开', '连接断开')
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on(SDKEventNames.INTERRUPTED, () => {
|
||||||
|
console.log('⚠️ [Client] Interrupted')
|
||||||
|
debugInfo.value.eventsReceived.push('client.interrupted')
|
||||||
|
addTimelineEvent('system', '⚠️ 被打断', 'AI 被用户打断')
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on(SDKEventNames.AUDIO_MUTED, () => {
|
||||||
|
console.log('🔇 [Client] Audio muted')
|
||||||
|
addTimelineEvent('audio', '🔇 麦克风关闭', '')
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on(SDKEventNames.AUDIO_UNMUTED, () => {
|
||||||
|
console.log('🔊 [Client] Audio unmuted')
|
||||||
|
addTimelineEvent('audio', '🔊 麦克风开启', '')
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on(SDKEventNames.ERROR, (error: unknown) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error('❌ [Client] Error:', error)
|
||||||
|
debugInfo.value.errors.push(errorMessage)
|
||||||
|
addTimelineEvent('error', '❌ 错误', errorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听所有事件(调试用)
|
||||||
|
client.on(SDKEventNames.ALL, (eventName: string, data: unknown) => {
|
||||||
|
// 避免重复日志,只记录非 server/client 前缀的事件
|
||||||
|
if (!eventName.startsWith('server.') && !eventName.startsWith('client.')) {
|
||||||
|
console.log(`📬 [ALL] Event: ${eventName}`, data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ Event listeners set up complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 session.update 信令事件
|
||||||
|
*
|
||||||
|
* 根据 Coze 官方文档 signaling_uplink_event:
|
||||||
|
* - 在 bot.join 后发送
|
||||||
|
* - data.chat_config.parameters: 传递对话流自定义参数(如 session_id)
|
||||||
|
* - data.chat_config.allow_voice_interrupt: 是否允许语音打断
|
||||||
|
* - data.turn_detection: VAD 配置
|
||||||
|
*
|
||||||
|
* 注意:只发送一次,避免 Bot 重连时重复发送导致对话重置
|
||||||
|
*/
|
||||||
|
function sendSessionUpdate(sid: string) {
|
||||||
|
if (!client || !sid) {
|
||||||
|
console.warn('Cannot send session.update: client or sessionId is missing')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止重复发送(Bot 重连时会再次触发 bot.join 事件)
|
||||||
|
if (isSessionUpdateSent.value) {
|
||||||
|
console.log('⚠️ session.update already sent, skipping to prevent conversation reset')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造 session.update 事件(严格按照 Coze 文档格式)
|
||||||
|
const event = {
|
||||||
|
id: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
event_type: 'session.update',
|
||||||
|
data: {
|
||||||
|
// 会话配置
|
||||||
|
chat_config: {
|
||||||
|
// 传递对话流自定义参数
|
||||||
|
parameters: {
|
||||||
|
session_id: sid,
|
||||||
|
},
|
||||||
|
// 禁止语音打断 AI
|
||||||
|
allow_voice_interrupt: AUDIO_CONFIG.allowVoiceInterrupt,
|
||||||
|
},
|
||||||
|
// VAD 声音检测配置
|
||||||
|
turn_detection: {
|
||||||
|
type: 'server_vad', // 服务端 VAD
|
||||||
|
silence_duration_ms: AUDIO_CONFIG.vad.silenceDurationMs, // 静音 2 秒判定说完(范围 200~2000)
|
||||||
|
prefix_padding_ms: AUDIO_CONFIG.vad.prefixPaddingMs, // 前置填充 600ms
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📤 Sending session.update:', JSON.stringify(event, null, 2))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 sendMessage 发送上行信令
|
||||||
|
client.sendMessage(event)
|
||||||
|
console.log('✅ session.update sent successfully!')
|
||||||
|
console.log(`📊 VAD: silence=${AUDIO_CONFIG.vad.silenceDurationMs}ms, prefix=${AUDIO_CONFIG.vad.prefixPaddingMs}ms`)
|
||||||
|
console.log(`🔇 Voice interrupt: ${AUDIO_CONFIG.allowVoiceInterrupt ? 'enabled' : 'disabled'}`)
|
||||||
|
debugInfo.value.eventsSent.push('session.update')
|
||||||
|
isSessionUpdateSent.value = true
|
||||||
|
debugInfo.value.sessionUpdateSent = true
|
||||||
|
return true
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error('❌ Failed to send session.update:', error)
|
||||||
|
debugInfo.value.errors.push(`sendMessage failed: ${errorMessage}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开连接
|
||||||
|
*/
|
||||||
|
async function disconnect() {
|
||||||
|
console.log('🔌 Disconnecting from Coze Realtime...')
|
||||||
|
try {
|
||||||
|
// 先停止本地 VAD
|
||||||
|
stopLocalVAD()
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
// 先清除事件监听器
|
||||||
|
try {
|
||||||
|
client.clearEventHandlers?.()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('clearEventHandlers failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断开连接(可能是异步的)
|
||||||
|
try {
|
||||||
|
await client.disconnect()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('disconnect call failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
client = null
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionState.value = 'disconnected'
|
||||||
|
isSessionUpdateSent.value = false
|
||||||
|
debugInfo.value.rtcConnected = false
|
||||||
|
debugInfo.value.sessionUpdateSent = false
|
||||||
|
debugInfo.value.botJoined = false
|
||||||
|
console.log('✅ Coze Realtime disconnected successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Disconnect error:', error)
|
||||||
|
// 即使出错也要重置状态
|
||||||
|
stopLocalVAD()
|
||||||
|
connectionState.value = 'disconnected'
|
||||||
|
client = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换静音
|
||||||
|
*/
|
||||||
|
async function toggleMute(mute: boolean) {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.setAudioEnable(!mute)
|
||||||
|
isMuted.value = mute
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Toggle mute error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打断 AI
|
||||||
|
*/
|
||||||
|
function interrupt() {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
client.interrupt()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Interrupt error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交当前语音输入(手动触发 VAD 结束)
|
||||||
|
* 发送 input_audio_buffer.commit 信令告诉服务器用户已说完
|
||||||
|
*/
|
||||||
|
function commitAudioInput() {
|
||||||
|
if (!client) {
|
||||||
|
console.warn('Cannot commit audio: client is not connected')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 立刻停止录音状态(用户点击了"说完了")
|
||||||
|
isSpeaking.value = false
|
||||||
|
silenceStartTime = null
|
||||||
|
speechStartTime = null
|
||||||
|
hasCommittedThisTurn = true
|
||||||
|
addTimelineEvent('send', '🛑 手动停止录音', '用户点击说完了')
|
||||||
|
|
||||||
|
// 方案 1: 发送 input_audio_buffer.commit 事件(OpenAI 兼容格式)
|
||||||
|
const commitEvent = {
|
||||||
|
id: `evt_${Date.now()}_commit`,
|
||||||
|
event_type: 'input_audio_buffer.commit',
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📤 Sending input_audio_buffer.commit (手动触发 VAD 结束)...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.sendMessage(commitEvent)
|
||||||
|
console.log('✅ Audio input committed!')
|
||||||
|
addTimelineEvent('send', '📤 语音已提交', '手动触发')
|
||||||
|
debugInfo.value.eventsSent.push('input_audio_buffer.commit')
|
||||||
|
return true
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error('❌ Failed to commit audio:', error)
|
||||||
|
|
||||||
|
// 方案 2: 如果 commit 不支持,尝试发送 conversation.item.create
|
||||||
|
console.log('📤 Trying alternative: conversation.item.create...')
|
||||||
|
try {
|
||||||
|
const createEvent = {
|
||||||
|
id: `evt_${Date.now()}_create`,
|
||||||
|
event_type: 'conversation.item.create',
|
||||||
|
data: {
|
||||||
|
item: {
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_audio', audio: '' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.sendMessage(createEvent)
|
||||||
|
console.log('✅ Alternative event sent!')
|
||||||
|
return true
|
||||||
|
} catch (altError) {
|
||||||
|
console.error('❌ Alternative also failed:', altError)
|
||||||
|
debugInfo.value.errors.push(`commitAudio failed: ${errorMessage}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取调试信息摘要
|
||||||
|
*/
|
||||||
|
const debugSummary = computed(() => {
|
||||||
|
return {
|
||||||
|
...debugInfo.value,
|
||||||
|
connectionState: connectionState.value,
|
||||||
|
isMuted: isMuted.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionState,
|
||||||
|
isMuted,
|
||||||
|
sessionId,
|
||||||
|
isSessionUpdateSent,
|
||||||
|
lastError,
|
||||||
|
debugInfo,
|
||||||
|
debugSummary,
|
||||||
|
timeline, // 通讯时间线
|
||||||
|
// 本地 VAD 状态
|
||||||
|
isSpeaking,
|
||||||
|
isAISpeaking, // AI 是否正在说话
|
||||||
|
currentVolume,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
toggleMute,
|
||||||
|
interrupt,
|
||||||
|
commitAudioInput, // 手动触发 VAD 结束(说完了)
|
||||||
|
}
|
||||||
|
}
|
||||||
468
frontend/src/composables/useRTC.ts
Normal file
468
frontend/src/composables/useRTC.ts
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { RTCConnectionState } from '@/types'
|
||||||
|
|
||||||
|
// RTC 连接参数
|
||||||
|
interface RTCConnectParams {
|
||||||
|
appId: string
|
||||||
|
roomId: string
|
||||||
|
userId: string
|
||||||
|
token: string
|
||||||
|
sessionId?: string // 面试会话 ID,用于传递给工作流
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 火山引擎 RTC Hook
|
||||||
|
* 封装 RTC 连接、断开、静音、TTS 发送等操作
|
||||||
|
*/
|
||||||
|
export function useRTC() {
|
||||||
|
const connectionState = ref<RTCConnectionState>('disconnected')
|
||||||
|
const isMuted = ref(false)
|
||||||
|
const isReceiveOnly = ref(false) // 仅接收模式
|
||||||
|
const isTTSSending = ref(false) // TTS 正在发送
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let engine: any = null
|
||||||
|
let audioContext: AudioContext | null = null
|
||||||
|
let ttsDestination: MediaStreamAudioDestinationNode | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 TTS 音频上下文
|
||||||
|
* 创建一个虚拟音频目标,用于捕获 TTS 输出
|
||||||
|
*/
|
||||||
|
function initTTSAudio() {
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new AudioContext()
|
||||||
|
ttsDestination = audioContext.createMediaStreamDestination()
|
||||||
|
console.log('TTS audio context initialized')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接 RTC 房间(TTS 模式:使用 TTS 模拟语音输入)
|
||||||
|
*
|
||||||
|
* 工作原理:
|
||||||
|
* 1. 创建虚拟音频设备(MediaStreamAudioDestinationNode)
|
||||||
|
* 2. TTS 输出连接到虚拟设备
|
||||||
|
* 3. RTC 从虚拟设备采集音频发送
|
||||||
|
*/
|
||||||
|
async function connectTTSMode(params: RTCConnectParams) {
|
||||||
|
try {
|
||||||
|
connectionState.value = 'connecting'
|
||||||
|
isReceiveOnly.value = false // TTS 模式可以发送
|
||||||
|
|
||||||
|
// 初始化 TTS 音频
|
||||||
|
initTTSAudio()
|
||||||
|
|
||||||
|
// 动态导入 RTC SDK
|
||||||
|
console.log('Loading RTC SDK (TTS mode)...')
|
||||||
|
const VERTC = await import('@volcengine/rtc')
|
||||||
|
|
||||||
|
// 创建引擎
|
||||||
|
console.log('Creating RTC engine with appId:', params.appId)
|
||||||
|
engine = VERTC.default.createEngine(params.appId)
|
||||||
|
|
||||||
|
// 监听事件
|
||||||
|
engine.on('onUserJoined', (event: { userId: string }) => {
|
||||||
|
console.log('User joined:', event.userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.on('onUserLeave', (event: { userId: string }) => {
|
||||||
|
console.log('User left:', event.userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.on('onError', (error: Error) => {
|
||||||
|
console.error('RTC error:', error)
|
||||||
|
connectionState.value = 'failed'
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.on('onRoomStateChanged', (event: { state: number; errorCode: number }) => {
|
||||||
|
console.log('Room state changed:', event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听远端音频(AI 的回复)
|
||||||
|
engine.on('onRemoteAudioPropertiesReport', (event: any) => {
|
||||||
|
if (event && event.length > 0) {
|
||||||
|
console.log('AI is speaking:', event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加入房间
|
||||||
|
console.log('Joining room (TTS mode):', params.roomId)
|
||||||
|
await engine.joinRoom(
|
||||||
|
params.token,
|
||||||
|
params.roomId,
|
||||||
|
{
|
||||||
|
userId: params.userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isAutoPublish: true, // 自动发布(TTS 音频)
|
||||||
|
isAutoSubscribeAudio: true, // 接收 AI 语音
|
||||||
|
isAutoSubscribeVideo: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 设置自定义音频轨道
|
||||||
|
if (ttsDestination) {
|
||||||
|
const stream = ttsDestination.stream
|
||||||
|
const audioTrack = stream.getAudioTracks()[0]
|
||||||
|
|
||||||
|
if (audioTrack) {
|
||||||
|
console.log('Setting custom audio track for TTS...')
|
||||||
|
// 使用 setAudioTrack 或 replaceTrack API
|
||||||
|
try {
|
||||||
|
// 方法1: 尝试使用 setCustomizeAudioTrack
|
||||||
|
if (engine.setCustomizeAudioTrack) {
|
||||||
|
await engine.setCustomizeAudioTrack(audioTrack)
|
||||||
|
console.log('Custom audio track set via setCustomizeAudioTrack')
|
||||||
|
}
|
||||||
|
// 方法2: 尝试使用外部音频采集
|
||||||
|
else if (engine.setExternalAudioSource) {
|
||||||
|
await engine.setExternalAudioSource(true, 48000, 1)
|
||||||
|
console.log('External audio source enabled')
|
||||||
|
}
|
||||||
|
// 方法3: 开始采集但不使用默认设备
|
||||||
|
else {
|
||||||
|
console.log('Using default audio capture (TTS may not work)')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to set custom audio track:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始音频采集(使用静音或自定义源)
|
||||||
|
try {
|
||||||
|
await engine.startAudioCapture()
|
||||||
|
console.log('Audio capture started')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Audio capture failed (expected in TTS mode):', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionState.value = 'connected'
|
||||||
|
console.log('RTC connected successfully (TTS mode)')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('RTC connect error:', error)
|
||||||
|
connectionState.value = 'failed'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 TTS 发送文字到 RTC 房间
|
||||||
|
* 将文字转换为语音并通过 RTC 发送
|
||||||
|
*/
|
||||||
|
async function sendTTSMessage(text: string): Promise<void> {
|
||||||
|
if (!audioContext || !ttsDestination) {
|
||||||
|
console.error('TTS audio not initialized')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isTTSSending.value = true
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!window.speechSynthesis) {
|
||||||
|
isTTSSending.value = false
|
||||||
|
reject(new Error('浏览器不支持语音合成'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
|
utterance.lang = 'zh-CN'
|
||||||
|
utterance.rate = 1.0
|
||||||
|
utterance.pitch = 1.0
|
||||||
|
utterance.volume = 1.0
|
||||||
|
|
||||||
|
// 选择中文语音
|
||||||
|
const voices = window.speechSynthesis.getVoices()
|
||||||
|
const chineseVoice = voices.find(v => v.lang.includes('zh'))
|
||||||
|
if (chineseVoice) {
|
||||||
|
utterance.voice = chineseVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.onend = () => {
|
||||||
|
console.log('TTS finished:', text)
|
||||||
|
isTTSSending.value = false
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.onerror = (event) => {
|
||||||
|
console.error('TTS error:', event)
|
||||||
|
isTTSSending.value = false
|
||||||
|
reject(new Error('语音合成失败'))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('TTS speaking:', text)
|
||||||
|
window.speechSynthesis.speak(utterance)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接 RTC 房间(正常麦克风模式)
|
||||||
|
*/
|
||||||
|
async function connect(params: RTCConnectParams) {
|
||||||
|
try {
|
||||||
|
connectionState.value = 'connecting'
|
||||||
|
isReceiveOnly.value = false
|
||||||
|
|
||||||
|
// 1. 先请求麦克风权限
|
||||||
|
console.log('Requesting microphone permission...')
|
||||||
|
await requestMicrophonePermission()
|
||||||
|
|
||||||
|
// 2. 动态导入 RTC SDK
|
||||||
|
console.log('Loading RTC SDK...')
|
||||||
|
const VERTC = await import('@volcengine/rtc')
|
||||||
|
|
||||||
|
// 3. 创建引擎
|
||||||
|
console.log('Creating RTC engine with appId:', params.appId)
|
||||||
|
engine = VERTC.default.createEngine(params.appId)
|
||||||
|
|
||||||
|
// 4. 监听事件
|
||||||
|
engine.on('onUserJoined', (event: { userId: string }) => {
|
||||||
|
console.log('User joined:', event.userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.on('onUserLeave', (event: { userId: string }) => {
|
||||||
|
console.log('User left:', event.userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.on('onError', (error: Error) => {
|
||||||
|
console.error('RTC error:', error)
|
||||||
|
connectionState.value = 'failed'
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.on('onRoomStateChanged', (event: { state: number; errorCode: number }) => {
|
||||||
|
console.log('Room state changed:', event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. 加入房间
|
||||||
|
console.log('Joining room:', params.roomId)
|
||||||
|
await engine.joinRoom(
|
||||||
|
params.token,
|
||||||
|
params.roomId,
|
||||||
|
{
|
||||||
|
userId: params.userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isAutoPublish: true,
|
||||||
|
isAutoSubscribeAudio: true,
|
||||||
|
isAutoSubscribeVideo: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 6. 开始音频采集
|
||||||
|
console.log('Starting audio capture...')
|
||||||
|
await engine.startAudioCapture()
|
||||||
|
|
||||||
|
// 7. 发送 session.update 事件,传递 session_id 给工作流
|
||||||
|
if (params.sessionId) {
|
||||||
|
console.log('Sending session.update event with session_id:', params.sessionId)
|
||||||
|
await sendSessionUpdate(engine, params.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionState.value = 'connected'
|
||||||
|
console.log('RTC connected successfully')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('RTC connect error:', error)
|
||||||
|
connectionState.value = 'failed'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开 RTC 连接
|
||||||
|
*/
|
||||||
|
async function disconnect() {
|
||||||
|
try {
|
||||||
|
if (engine) {
|
||||||
|
try {
|
||||||
|
await engine.stopAudioCapture()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Stop audio capture failed:', e)
|
||||||
|
}
|
||||||
|
await engine.leaveRoom()
|
||||||
|
engine.destroy()
|
||||||
|
engine = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 TTS 音频上下文
|
||||||
|
if (audioContext) {
|
||||||
|
await audioContext.close()
|
||||||
|
audioContext = null
|
||||||
|
ttsDestination = null
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionState.value = 'disconnected'
|
||||||
|
isReceiveOnly.value = false
|
||||||
|
isTTSSending.value = false
|
||||||
|
console.log('RTC disconnected')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('RTC disconnect error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换静音状态
|
||||||
|
*/
|
||||||
|
function toggleMute(mute: boolean) {
|
||||||
|
if (engine) {
|
||||||
|
if (mute) {
|
||||||
|
engine.muteLocalAudio()
|
||||||
|
} else {
|
||||||
|
engine.unmuteLocalAudio()
|
||||||
|
}
|
||||||
|
isMuted.value = mute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionState,
|
||||||
|
isMuted,
|
||||||
|
isReceiveOnly,
|
||||||
|
isTTSSending,
|
||||||
|
connect,
|
||||||
|
connectTTSMode,
|
||||||
|
disconnect,
|
||||||
|
toggleMute,
|
||||||
|
sendTTSMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 session.update 事件,将 session_id 传递给 Coze 工作流
|
||||||
|
*
|
||||||
|
* 在 Coze RTC 语音模式下,需要通过此事件传递自定义参数
|
||||||
|
* 工作流可以通过入参获取 session_id
|
||||||
|
*
|
||||||
|
* 根据 Coze 文档,事件需要通过信令通道发送
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function sendSessionUpdate(engine: any, sessionId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 构造 session.update 事件(Coze 标准格式)
|
||||||
|
const eventData = {
|
||||||
|
id: `event_${Date.now()}`,
|
||||||
|
event_type: 'session.update',
|
||||||
|
data: {
|
||||||
|
chat_config: {
|
||||||
|
parameters: {
|
||||||
|
session_id: sessionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventJson = JSON.stringify(eventData)
|
||||||
|
console.log('Sending session.update event:', eventData)
|
||||||
|
|
||||||
|
// 尝试多种方式发送事件到 Coze
|
||||||
|
|
||||||
|
// 方法1: sendStreamSyncInfo (火山引擎 RTC 流同步信息)
|
||||||
|
if (engine.sendStreamSyncInfo) {
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(eventJson)
|
||||||
|
await engine.sendStreamSyncInfo(data, { repeatCount: 3 })
|
||||||
|
console.log('session.update sent via sendStreamSyncInfo')
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sendStreamSyncInfo failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2: sendSEIMessage (Supplemental Enhancement Information)
|
||||||
|
if (engine.sendSEIMessage) {
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(eventJson)
|
||||||
|
// streamIndex: 0 表示主流,1 表示屏幕流
|
||||||
|
await engine.sendSEIMessage(0, data, { repeatCount: 3 })
|
||||||
|
console.log('session.update sent via sendSEIMessage')
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sendSEIMessage failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3: sendPublicStreamSEIMessage
|
||||||
|
if (engine.sendPublicStreamSEIMessage) {
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(eventJson)
|
||||||
|
await engine.sendPublicStreamSEIMessage(data)
|
||||||
|
console.log('session.update sent via sendPublicStreamSEIMessage')
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sendPublicStreamSEIMessage failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法4: sendRoomMessage (房间消息)
|
||||||
|
if (engine.sendRoomMessage) {
|
||||||
|
try {
|
||||||
|
await engine.sendRoomMessage(eventJson)
|
||||||
|
console.log('session.update sent via sendRoomMessage')
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sendRoomMessage failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法5: sendUserMessage (用户消息)
|
||||||
|
if (engine.sendUserMessage) {
|
||||||
|
try {
|
||||||
|
// 发送给 AI Bot(Coze Bot 在房间中的用户 ID 通常以 bot_ 或 uid_ 开头)
|
||||||
|
await engine.sendUserMessage('', eventJson) // 空字符串表示广播
|
||||||
|
console.log('session.update sent via sendUserMessage')
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sendUserMessage failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法6: 尝试直接调用 engine 上的其他方法
|
||||||
|
console.log('Available engine methods:', Object.keys(engine).filter(k => typeof engine[k] === 'function'))
|
||||||
|
|
||||||
|
console.warn('No suitable method found to send session.update event. Coze may not receive the parameters.')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send session.update:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求麦克风权限
|
||||||
|
*/
|
||||||
|
async function requestMicrophonePermission(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (navigator.permissions) {
|
||||||
|
const result = await navigator.permissions.query({ name: 'microphone' as PermissionName })
|
||||||
|
console.log('Microphone permission status:', result.state)
|
||||||
|
|
||||||
|
if (result.state === 'denied') {
|
||||||
|
throw new Error('麦克风权限被拒绝,请在浏览器设置中允许访问麦克风')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
|
||||||
|
console.log('Microphone permission granted')
|
||||||
|
return true
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Microphone permission error:', error)
|
||||||
|
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
throw new Error('请允许使用麦克风以进行语音面试。如果您已拒绝,请在浏览器地址栏左侧的锁图标中重新授权。')
|
||||||
|
} else if (error.name === 'NotFoundError') {
|
||||||
|
throw new Error('未检测到麦克风设备,请确保您的设备已连接麦克风。')
|
||||||
|
} else {
|
||||||
|
throw new Error(`麦克风权限获取失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend/src/layouts/AdminLayout.vue
Normal file
62
frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const isCollapsed = ref(false)
|
||||||
|
|
||||||
|
function goToCandidates() {
|
||||||
|
router.push('/admin')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-container class="admin-layout h-screen">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<el-aside :width="isCollapsed ? '64px' : '200px'" class="bg-gray-800 transition-all">
|
||||||
|
<div class="h-14 flex items-center justify-center text-white font-bold text-lg border-b border-gray-700">
|
||||||
|
<span v-if="!isCollapsed">AI面试后台</span>
|
||||||
|
<span v-else>AI</span>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
:collapse="isCollapsed"
|
||||||
|
:default-active="$route.path"
|
||||||
|
background-color="#1f2937"
|
||||||
|
text-color="#9ca3af"
|
||||||
|
active-text-color="#3b82f6"
|
||||||
|
class="border-none"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/admin" @click="goToCandidates">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
<template #title>候选人管理</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<el-header class="bg-white border-b border-gray-200 flex items-center justify-between px-4">
|
||||||
|
<el-button :icon="isCollapsed ? 'Expand' : 'Fold'" text @click="isCollapsed = !isCollapsed" />
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-gray-600">管理员</span>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<el-main class="bg-gray-50 p-6">
|
||||||
|
<RouterView />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-layout :deep(.el-menu) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout :deep(.el-menu--collapse) {
|
||||||
|
width: 64px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
frontend/src/layouts/InterviewLayout.vue
Normal file
19
frontend/src/layouts/InterviewLayout.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="interview-layout min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.interview-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
frontend/src/main.ts
Normal file
22
frontend/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import './styles/index.css'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 注册 Element Plus 图标
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
192
frontend/src/pages/admin/[id].vue
Normal file
192
frontend/src/pages/admin/[id].vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { candidateApi } from '@/api'
|
||||||
|
import type { CandidateDetail } from '@/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const candidate = ref<CandidateDetail | null>(null)
|
||||||
|
|
||||||
|
// 雷达图数据
|
||||||
|
const radarData = computed(() => {
|
||||||
|
if (!candidate.value?.scores) return []
|
||||||
|
const { salesSkill, salesMindset, quality, motivation } = candidate.value.scores
|
||||||
|
return [
|
||||||
|
{ name: '销售技能', value: salesSkill },
|
||||||
|
{ name: '销售观', value: salesMindset },
|
||||||
|
{ name: '素质项', value: quality },
|
||||||
|
{ name: '求职动机', value: motivation },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取候选人详情
|
||||||
|
async function fetchDetail() {
|
||||||
|
const sessionId = route.params.id as string
|
||||||
|
if (!sessionId) {
|
||||||
|
router.push('/admin')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await candidateApi.getDetail(sessionId)
|
||||||
|
candidate.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch detail error:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出 PDF
|
||||||
|
function handleExport() {
|
||||||
|
if (!candidate.value) return
|
||||||
|
const url = candidateApi.exportPdf(candidate.value.sessionId)
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回列表
|
||||||
|
function goBack() {
|
||||||
|
router.push('/admin')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
function formatDate(dateStr?: string) {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取评分颜色
|
||||||
|
function getScoreColor(score: number) {
|
||||||
|
if (score >= 80) return '#22c55e'
|
||||||
|
if (score >= 60) return '#f59e0b'
|
||||||
|
return '#ef4444'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDetail()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="candidate-detail">
|
||||||
|
<!-- 返回按钮 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<el-button text @click="goBack">
|
||||||
|
<el-icon class="mr-1"><ArrowLeft /></el-icon>
|
||||||
|
返回列表
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="loading">
|
||||||
|
<template v-if="candidate">
|
||||||
|
<!-- 基本信息卡片 -->
|
||||||
|
<div class="bg-white rounded-lg p-6 mb-6 shadow-sm">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 mb-2">{{ candidate.name }}</h1>
|
||||||
|
<div class="text-gray-500 space-y-1">
|
||||||
|
<p>会话 ID:{{ candidate.sessionId }}</p>
|
||||||
|
<p>面试时间:{{ formatDate(candidate.createdAt) }}</p>
|
||||||
|
<p v-if="candidate.completedAt">
|
||||||
|
完成时间:{{ formatDate(candidate.completedAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="handleExport">
|
||||||
|
<el-icon class="mr-1"><Download /></el-icon>
|
||||||
|
导出 PDF
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评分概览 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- 综合评分 -->
|
||||||
|
<div class="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">综合评分</h2>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div
|
||||||
|
class="text-6xl font-bold mb-2"
|
||||||
|
:style="{ color: getScoreColor(candidate.scores?.total || 0) }"
|
||||||
|
>
|
||||||
|
{{ candidate.scores?.total || 0 }}
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500">总分(满分 100)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分项评分 -->
|
||||||
|
<div class="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">分项评分</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="item in radarData"
|
||||||
|
:key="item.name"
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<span class="w-20 text-gray-600">{{ item.name }}</span>
|
||||||
|
<el-progress
|
||||||
|
:percentage="item.value"
|
||||||
|
:stroke-width="12"
|
||||||
|
:color="getScoreColor(item.value)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right font-medium"
|
||||||
|
:style="{ color: getScoreColor(item.value) }"
|
||||||
|
>
|
||||||
|
{{ item.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分析报告 -->
|
||||||
|
<div class="bg-white rounded-lg p-6 mb-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">分析报告</h2>
|
||||||
|
<div class="prose max-w-none text-gray-600 leading-relaxed">
|
||||||
|
{{ candidate.analysis || '暂无分析报告' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 简历内容 -->
|
||||||
|
<div class="bg-white rounded-lg p-6 mb-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">简历内容</h2>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 text-gray-600 whitespace-pre-wrap">
|
||||||
|
{{ candidate.resume || '暂无简历内容' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 面试记录 -->
|
||||||
|
<div class="bg-white rounded-lg p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">面试记录</h2>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 text-gray-600 whitespace-pre-wrap max-h-96 overflow-y-auto">
|
||||||
|
{{ candidate.interviewLog || '暂无面试记录' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="!loading">
|
||||||
|
<el-empty description="未找到候选人信息" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose {
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user