CareerBot/app/routers/admin.py
ln0422 501f8985ec Add /careerbot base path for www.ityb.me/careerbot deployment
- Add BASE_PATH config, include all routers with prefix
- Inject {{ base }} Jinja2 global for all template URLs
- Add window.BASE_PATH for static JS files
- Update Nginx to proxy /careerbot/ path
- Add OPS_MANUAL.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 22:07:34 +08:00

630 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
BASE = settings.BASE_PATH
from app.database import get_db
from app.models import (
Profile, Skill, Education, WorkExperience,
AccessToken, LLMConfig, RecruiterMessage,
)
from app.routers.auth import get_current_admin
router = APIRouter()
templates = Jinja2Templates(directory="templates")
# ── Admin Page Routes ──
@router.get("/admin/login", response_class=HTMLResponse)
def admin_login_page(request: Request):
return templates.TemplateResponse(request, "admin/login.html")
@router.get("/admin/dashboard", response_class=HTMLResponse)
def admin_dashboard(request: Request):
return templates.TemplateResponse(request, "admin/dashboard.html")
@router.get("/admin/{page}", response_class=HTMLResponse)
def admin_page(request: Request, page: str):
valid_pages = ["profile", "education", "experience", "skills", "tokens", "llm-config", "resume", "messages"]
if page not in valid_pages:
raise HTTPException(status_code=404)
template_name = f"admin/{page}.html"
return templates.TemplateResponse(request, template_name)
# ── Profile API ──
class ProfileUpdate(BaseModel):
name: str = ""
phone: str = ""
location: str = ""
birthday: str = ""
party: str = ""
education_level: str = ""
email: str = ""
self_summary: str = ""
@router.get("/api/admin/profile")
def get_profile(db: Session = Depends(get_db), _=Depends(get_current_admin)):
p = db.query(Profile).first()
if not p:
return {}
return {
"id": p.id, "name": p.name, "phone": p.phone, "location": p.location,
"birthday": p.birthday, "party": p.party, "education_level": p.education_level,
"email": p.email, "photo_url": p.photo_url, "self_summary": p.self_summary,
}
@router.put("/api/admin/profile")
def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
p = db.query(Profile).first()
if not p:
p = Profile()
db.add(p)
for key, val in data.model_dump().items():
setattr(p, key, val)
db.commit()
db.refresh(p)
return {"message": "ok", "id": p.id}
@router.post("/api/admin/profile/photo")
async def upload_photo(
file: UploadFile = File(...),
db: Session = Depends(get_db),
_=Depends(get_current_admin),
):
ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "jpg"
filename = f"photo_{uuid.uuid4().hex[:8]}.{ext}"
filepath = os.path.join(settings.UPLOAD_DIR, filename)
content = await file.read()
with open(filepath, "wb") as f:
f.write(content)
p = db.query(Profile).first()
if not p:
p = Profile()
db.add(p)
p.photo_url = f"{BASE}/uploads/{filename}"
db.commit()
return {"photo_url": p.photo_url}
# ── Education CRUD ──
class EducationData(BaseModel):
start_date: str = ""
end_date: str = ""
school: str = ""
major: str = ""
degree: str = ""
details: str = ""
sort_order: int = 0
@router.get("/api/admin/education")
def list_education(db: Session = Depends(get_db), _=Depends(get_current_admin)):
items = db.query(Education).order_by(Education.sort_order).all()
return [
{"id": e.id, "start_date": e.start_date, "end_date": e.end_date,
"school": e.school, "major": e.major, "degree": e.degree,
"details": e.details, "sort_order": e.sort_order}
for e in items
]
@router.post("/api/admin/education")
def create_education(data: EducationData, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = Education(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return {"id": item.id, "message": "ok"}
@router.put("/api/admin/education/{item_id}")
def update_education(item_id: int, data: EducationData, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(Education).filter(Education.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
for key, val in data.model_dump().items():
setattr(item, key, val)
db.commit()
return {"message": "ok"}
@router.delete("/api/admin/education/{item_id}")
def delete_education(item_id: int, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(Education).filter(Education.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
db.delete(item)
db.commit()
return {"message": "ok"}
# ── Work Experience CRUD ──
class ExperienceData(BaseModel):
start_date: str = ""
end_date: str = ""
company: str = ""
position: str = ""
company_intro: str = ""
responsibilities: str = ""
achievements: str = ""
sort_order: int = 0
@router.get("/api/admin/experience")
def list_experience(db: Session = Depends(get_db), _=Depends(get_current_admin)):
items = db.query(WorkExperience).order_by(WorkExperience.sort_order).all()
return [
{"id": e.id, "start_date": e.start_date, "end_date": e.end_date,
"company": e.company, "position": e.position, "company_intro": e.company_intro,
"responsibilities": e.responsibilities, "achievements": e.achievements,
"sort_order": e.sort_order}
for e in items
]
@router.post("/api/admin/experience")
def create_experience(data: ExperienceData, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = WorkExperience(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return {"id": item.id, "message": "ok"}
@router.put("/api/admin/experience/{item_id}")
def update_experience(item_id: int, data: ExperienceData, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(WorkExperience).filter(WorkExperience.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
for key, val in data.model_dump().items():
setattr(item, key, val)
db.commit()
return {"message": "ok"}
@router.delete("/api/admin/experience/{item_id}")
def delete_experience(item_id: int, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(WorkExperience).filter(WorkExperience.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
db.delete(item)
db.commit()
return {"message": "ok"}
# ── Skills CRUD ──
class SkillData(BaseModel):
category: str = ""
content: str = ""
sort_order: int = 0
@router.get("/api/admin/skills")
def list_skills(db: Session = Depends(get_db), _=Depends(get_current_admin)):
items = db.query(Skill).order_by(Skill.sort_order).all()
return [
{"id": s.id, "category": s.category, "content": s.content, "sort_order": s.sort_order}
for s in items
]
@router.post("/api/admin/skills")
def create_skill(data: SkillData, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = Skill(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return {"id": item.id, "message": "ok"}
@router.put("/api/admin/skills/{item_id}")
def update_skill(item_id: int, data: SkillData, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(Skill).filter(Skill.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
for key, val in data.model_dump().items():
setattr(item, key, val)
db.commit()
return {"message": "ok"}
@router.delete("/api/admin/skills/{item_id}")
def delete_skill(item_id: int, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(Skill).filter(Skill.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
db.delete(item)
db.commit()
return {"message": "ok"}
# ── Access Tokens ──
class TokenCreate(BaseModel):
note: str = ""
max_questions: int | None = None
class TokenUpdate(BaseModel):
note: str | None = None
max_questions: int | None = None
used_questions: int | None = None
is_active: bool | None = None
@router.get("/api/admin/tokens")
def list_tokens(db: Session = Depends(get_db), _=Depends(get_current_admin)):
items = db.query(AccessToken).order_by(AccessToken.created_at.desc()).all()
return [
{"id": t.id, "token": t.token, "created_at": t.created_at.isoformat() if t.created_at else "",
"is_active": t.is_active, "note": t.note,
"max_questions": t.max_questions or 10, "used_questions": t.used_questions or 0}
for t in items
]
@router.post("/api/admin/tokens")
def create_token(data: TokenCreate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
token_str = uuid.uuid4().hex
# Use LLM config default if max_questions not specified
max_q = data.max_questions
if max_q is None:
llm_config = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
max_q = llm_config.max_questions_per_session if llm_config and llm_config.max_questions_per_session else 9
item = AccessToken(token=token_str, note=data.note, max_questions=max_q, created_at=datetime.utcnow())
db.add(item)
db.commit()
db.refresh(item)
return {"id": item.id, "token": item.token, "is_active": item.is_active,
"note": item.note, "max_questions": item.max_questions}
@router.put("/api/admin/tokens/{item_id}")
def update_token(item_id: int, data: TokenUpdate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(AccessToken).filter(AccessToken.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
if data.note is not None:
item.note = data.note
if data.max_questions is not None:
item.max_questions = data.max_questions
if data.used_questions is not None:
item.used_questions = data.used_questions
if data.is_active is not None:
item.is_active = data.is_active
db.commit()
return {"message": "ok", "is_active": item.is_active}
@router.delete("/api/admin/tokens/{item_id}")
def delete_token(item_id: int, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(AccessToken).filter(AccessToken.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
db.delete(item)
db.commit()
return {"message": "ok"}
# ── LLM Config ──
class LLMConfigData(BaseModel):
api_url: str = ""
api_key: str = ""
model_name: str = ""
max_tokens: int = 2048
temperature: float = 0.7
max_questions_per_session: int = 9
system_prompt: str = ""
@router.get("/api/admin/llm-config")
def get_llm_config(db: Session = Depends(get_db), _=Depends(get_current_admin)):
config = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
if not config:
return {}
return {
"id": config.id, "api_url": config.api_url, "api_key": config.api_key,
"model_name": config.model_name, "max_tokens": config.max_tokens,
"temperature": config.temperature,
"max_questions_per_session": config.max_questions_per_session or 9,
"system_prompt": config.system_prompt or "",
}
@router.put("/api/admin/llm-config")
def update_llm_config(data: LLMConfigData, db: Session = Depends(get_db), _=Depends(get_current_admin)):
config = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
if not config:
config = LLMConfig(is_active=True)
db.add(config)
for key, val in data.model_dump().items():
setattr(config, key, val)
db.commit()
db.refresh(config)
return {"message": "ok", "id": config.id}
@router.post("/api/admin/llm-config/test")
async def test_llm_config(db: Session = Depends(get_db), _=Depends(get_current_admin)):
config = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
if not config or not config.api_url or not config.api_key:
return {"result": "Error: LLM not configured. Please save API URL and API Key first."}
from app.services.llm_service import chat_completion
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Please respond with exactly: OK"},
]
result = await chat_completion(messages, db)
return {"result": result}
# ── Resume Generator ──
@router.post("/api/admin/resume/generate")
async def generate_resume(
jd_text: str = Form(""),
file: UploadFile | None = File(None),
db: Session = Depends(get_db),
_=Depends(get_current_admin),
):
"""Generate a tailored resume based on JD, streaming response."""
import json
from sse_starlette.sse import EventSourceResponse
from app.services.file_parser import parse_file
from app.services.llm_service import chat_completion_stream
# Parse JD from file or text
jd_content = jd_text.strip()
if file and file.filename:
parsed = await parse_file(file)
if parsed.get("text"):
jd_content = parsed["text"]
elif parsed.get("error"):
async def error_gen():
yield {"data": json.dumps({"content": f"文件解析失败: {parsed['error']}"})}
yield {"data": json.dumps({"content": "", "done": True})}
return EventSourceResponse(error_gen())
if not jd_content:
async def empty_gen():
yield {"data": json.dumps({"content": "请输入或上传职位描述(JD)内容。"})}
yield {"data": json.dumps({"content": "", "done": True})}
return EventSourceResponse(empty_gen())
# Gather all profile data
profile = db.query(Profile).first()
skills = db.query(Skill).order_by(Skill.sort_order).all()
educations = db.query(Education).order_by(Education.sort_order).all()
experiences = db.query(WorkExperience).order_by(WorkExperience.sort_order).all()
# Build profile context
parts = []
if profile:
parts.append(f"姓名: {profile.name}")
parts.append(f"电话: {profile.phone}")
parts.append(f"邮箱: {profile.email}")
parts.append(f"所在地: {profile.location}")
parts.append(f"学历: {profile.education_level}")
if profile.self_summary:
parts.append(f"个人总结: {profile.self_summary}")
if skills:
parts.append("\n=== 核心技能 ===")
for s in skills:
parts.append(f"- {s.category}: {s.content}")
if educations:
parts.append("\n=== 教育经历 ===")
for e in educations:
parts.append(f"{e.start_date}-{e.end_date} | {e.school} | {e.major} | {e.degree}")
if e.details:
parts.append(f" {e.details}")
if experiences:
parts.append("\n=== 工作经历 ===")
for exp in experiences:
parts.append(f"\n{exp.start_date}-{exp.end_date} | {exp.company} | {exp.position}")
if exp.company_intro:
parts.append(f"公司简介: {exp.company_intro}")
if exp.responsibilities:
parts.append(f"工作职责: {exp.responsibilities}")
if exp.achievements:
parts.append(f"工作成就: {exp.achievements}")
profile_text = "\n".join(parts)
system_prompt = """你是一位专业的简历撰写顾问。请根据提供的职位描述(JD)和候选人的完整背景信息,生成一份针对该职位量身定制的中文简历。
要求:
1. 简历必须包含候选人的全部教育经历和工作经历,不得遗漏任何一段
2. "个人优势""自我评价"部分要针对JD进行定制突出与JD最匹配的能力和经验
3. 每段工作经历的描述要重点突出与JD相关的职责和成就弱化不相关的部分
4. 技能部分优先展示JD要求的技能
5. 使用专业、简洁的语言,避免空话套话
6. 输出格式使用Markdown结构清晰
简历结构:
# 个人信息
# 求职意向根据JD推断
# 个人优势3-5条针对JD定制
# 核心技能
# 工作经历(全部,按时间倒序)
# 教育背景(全部,按时间倒序)"""
user_prompt = f"""=== 目标职位描述(JD) ===
{jd_content}
=== 候选人完整背景 ===
{profile_text}
请根据以上JD生成一份量身定制的简历"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
async def event_gen():
async for chunk in chat_completion_stream(messages, db):
yield {"data": json.dumps({"content": chunk})}
yield {"data": json.dumps({"content": "", "done": True})}
return EventSourceResponse(event_gen())
@router.post("/api/admin/resume/download")
async def download_resume(
content: str = Form(...),
_=Depends(get_current_admin),
):
"""Convert markdown resume content to a Word document for download."""
from fastapi.responses import Response
from docx import Document
from docx.shared import Pt, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
import re
doc = Document()
# Page margins
for section in doc.sections:
section.top_margin = Inches(0.8)
section.bottom_margin = Inches(0.8)
section.left_margin = Inches(0.9)
section.right_margin = Inches(0.9)
# Parse markdown and build document
lines = content.split("\n")
for line in lines:
stripped = line.strip()
if not stripped:
continue
# H1 heading
if stripped.startswith("# ") and not stripped.startswith("## "):
p = doc.add_heading(stripped[2:].strip(), level=1)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
# H2 heading
elif stripped.startswith("## "):
doc.add_heading(stripped[3:].strip(), level=2)
# H3 heading
elif stripped.startswith("### "):
doc.add_heading(stripped[4:].strip(), level=3)
# Bullet list
elif stripped.startswith("- ") or stripped.startswith("* "):
text = stripped[2:].strip()
# Handle **bold** within text
p = doc.add_paragraph(style="List Bullet")
_add_rich_text(p, text)
# Numbered list
elif re.match(r"^\d+\.\s", stripped):
text = re.sub(r"^\d+\.\s", "", stripped)
p = doc.add_paragraph(style="List Number")
_add_rich_text(p, text)
# Horizontal rule
elif stripped in ("---", "***", "___"):
continue
# Normal paragraph
else:
p = doc.add_paragraph()
_add_rich_text(p, stripped)
import io
buffer = io.BytesIO()
doc.save(buffer)
buffer.seek(0)
return Response(
content=buffer.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": "attachment; filename=resume.docx"},
)
def _add_rich_text(paragraph, text: str):
"""Parse **bold** markers in text and add runs to paragraph."""
import re
parts = re.split(r"(\*\*.*?\*\*)", text)
for part in parts:
if part.startswith("**") and part.endswith("**"):
run = paragraph.add_run(part[2:-2])
run.bold = True
else:
paragraph.add_run(part)
# ── Recruiter Messages ──
@router.get("/api/admin/messages")
def list_messages(db: Session = Depends(get_db), _=Depends(get_current_admin)):
items = db.query(RecruiterMessage).order_by(RecruiterMessage.created_at.desc()).all()
return [
{
"id": m.id,
"session_id": m.session_id,
"visitor_label": m.visitor_label,
"company": m.company,
"contact": m.contact,
"intent": m.intent,
"summary": m.summary,
"is_read": m.is_read,
"created_at": m.created_at.isoformat() if m.created_at else "",
}
for m in items
]
@router.get("/api/admin/messages/unread-count")
def unread_message_count(db: Session = Depends(get_db), _=Depends(get_current_admin)):
count = db.query(RecruiterMessage).filter(RecruiterMessage.is_read == False).count()
return {"count": count}
@router.put("/api/admin/messages/{item_id}/read")
def mark_message_read(item_id: int, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(RecruiterMessage).filter(RecruiterMessage.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
item.is_read = True
db.commit()
return {"message": "ok"}
@router.put("/api/admin/messages/read-all")
def mark_all_read(db: Session = Depends(get_db), _=Depends(get_current_admin)):
db.query(RecruiterMessage).filter(RecruiterMessage.is_read == False).update({"is_read": True})
db.commit()
return {"message": "ok"}
@router.delete("/api/admin/messages/{item_id}")
def delete_message(item_id: int, db: Session = Depends(get_db), _=Depends(get_current_admin)):
item = db.query(RecruiterMessage).filter(RecruiterMessage.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
db.delete(item)
db.commit()
return {"message": "ok"}