Initial commit: AI Interview System

This commit is contained in:
111
2026-01-23 13:57:48 +08:00
commit 95770afe21
127 changed files with 24686 additions and 0 deletions

64
.drone.yml Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# AI Interview Backend App

55
backend/app/config.py Normal file
View 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()

View File

@@ -0,0 +1 @@
# Routers

View 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
})

View 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))

View 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 APIChatflow 对话)
# 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))

View 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"
)

View 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
View 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))

View 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
View 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

View File

@@ -0,0 +1,4 @@
# Services
from .coze_service import CozeService, coze_service
__all__ = ["CozeService", "coze_service"]

View 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 的 IDChatflow
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
View 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
View 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
View 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个月重点学习产品知识和销售流程23-6个月参与实战由资深顾问带教36个月后独立负责客户。预计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
View 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())

View 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
View 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
View 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
View 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())

View 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())

View 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())

View 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())

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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
View 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) - 技术栈说明

View 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
View 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"]

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View 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;
}

View 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
View 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
View 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
View 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 ""

View 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
View 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
View 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
View 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()

View 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
View 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 工作流 | 已有完整面试逻辑(工作流 ID7595077233002840079 |
| Coze 数据库 | 已有数据结构(数据库 ID7595077053909712922 |
### 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
View 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
View 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 | 是 | 会话 IDURL 参数) |
**响应示例**
```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 | 是 | 会话 IDURL 参数) |
**响应示例**
```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 | 是 | 会话 IDURL 参数) |
**响应**
- **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
View 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
View 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元/月(底薪+提成)

View 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
View 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
View File

@@ -0,0 +1,70 @@
# 决策记录
> ⛔ **版本规则**:只允许追加,禁止修改/删除已有记录
---
## 决策索引
| 编号 | 日期 | 主题 | 决策结果 |
|------|------|------|---------|
| DR-001 | 2026-01-20 | 技术选型与框架规范覆盖 | 使用 Coze API + 火山引擎 RTC |
---
## DR-001 | 2026-01-20
### 背景
项目需要实现 AI 语音面试系统。已有完整的 Coze 工作流(工作流 ID7595077233002840079包含面试逻辑、评分算法、报告生成等功能。需要确定技术选型并明确与 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
View 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
View 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
View 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
View 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 工作流 | 已有完整面试逻辑ID7595077233002840079 |
| Coze 数据库 | 已有数据结构ID7595077053909712922 |
---
## 变更日志
| 日期 | 变更内容 | 操作人 |
|------|---------|--------|
| 2026-01-20 | 更新阶段进度,添加技术栈详情 | AI |
| 2026-01-20 | 初始化快照 | AI |

View 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 |

View 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` APICoze 返回新的 `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

Binary file not shown.

13
frontend/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

10
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped>
</style>

View 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
View File

@@ -0,0 +1,11 @@
export { request } from './request'
export { candidateApi } from './candidate'
export type {
SubmitCandidateResponse,
CreateRoomRequest,
CreateRoomResponse,
Candidate,
CandidateDetail,
CandidateListResponse,
CandidateListParams,
} from './candidate'

View 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

View File

@@ -0,0 +1 @@
export { useRTC } from './useRTC'

View 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-115% 过滤环境噪音和回声
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 结束(说完了)
}
}

View 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 BotCoze 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}`)
}
}
}

View 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>

View 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
View 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')

View 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