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 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"/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"}