from fastapi import FastAPI, HTTPException, Request from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel import psycopg2 from psycopg2.extras import RealDictCursor from typing import Optional, List, Dict import random import uuid import os import difflib from dotenv import load_dotenv load_dotenv() app = FastAPI() # Database connection parameters DB_CONFIG = { "host": os.getenv("DB_HOST", "121.43.104.161"), "port": os.getenv("DB_PORT", "6432"), "user": os.getenv("DB_USER", "gsdh"), "password": os.getenv("DB_PASSWORD", "123gsdh"), "database": os.getenv("DB_NAME", "gsdh") } # Mount static files app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") # Models class CheckinRequest(BaseModel): gsdh_id: str name: str phone: str company_name: Optional[str] = None position: Optional[str] = None business_scope: Optional[str] = None vision_2026: Optional[str] = None location: Optional[str] = None class AddUserRequest(BaseModel): name: str phone: str industry_company: Optional[str] = None fee: Optional[str] = None payment_channel: Optional[str] = None def get_db_connection(): conn = psycopg2.connect(**DB_CONFIG) return conn def assign_seat(cur, user_industry: str) -> str: """ Allocate a seat based on: 1. Even distribution (11 tables, max 12 per table) - Prioritize filling empty tables first (min count) - If counts are equal, fill sequentially (Table 1 before Table 2) 2. Mix industries (try to put user in a table where their industry is least represented) - Uses natural language similarity to judge industry overlap """ TOTAL_TABLES = 11 MAX_PER_TABLE = 12 # Initialize table stats tables = {i: {'count': 0, 'industries': []} for i in range(1, TOTAL_TABLES + 1)} # Fetch current seating status # Uses aggregation for efficiency as requested # We use array_agg to collect industries for the diversity check in one query query = """ SELECT ci.location, COUNT(ci.gsdh_id), array_agg(gd.industry_company) FROM checkin_info ci LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id WHERE ci.location IS NOT NULL AND ci.location LIKE '第%桌' GROUP BY ci.location """ cur.execute(query) rows = cur.fetchall() for row in rows: loc = row[0] count = row[1] industries = row[2] if row[2] else [] try: # Extract table number table_num = int(loc.replace("第", "").replace("桌", "")) if 1 <= table_num <= TOTAL_TABLES: tables[table_num]['count'] = count # Filter out None values from industries tables[table_num]['industries'] = [ind for ind in industries if ind] except ValueError: continue # Filter tables that are not full available_tables = [t for t in tables.items() if t[1]['count'] < MAX_PER_TABLE] if not available_tables: return "自由席" # Fallback if all full # Strategy 1: Find tables with Minimum Count # This automatically handles "Prioritize filling TOTAL_TABLES" because empty tables have count 0 min_count = min(t[1]['count'] for t in available_tables) candidates = [t for t in available_tables if t[1]['count'] == min_count] # Sort by table number to ensure sequential filling if counts are equal (Requirement: 顺序分配) candidates.sort(key=lambda x: x[0]) # Strategy 2: Optimize for Industry Diversity if not user_industry: # If no industry info, just pick the first one (Sequential) best_table = candidates[0][0] else: # Calculate similarity scores # Score = sum of similarity with existing users # Lower score is better (more unique) scored_candidates = [] for table_id, stats in candidates: total_similarity = 0.0 for existing_ind in stats['industries']: if existing_ind: # Use difflib for fuzzy matching (0.0 to 1.0) # This helps understand "Natural Language" industries better than exact match sim = difflib.SequenceMatcher(None, user_industry, existing_ind).ratio() total_similarity += sim scored_candidates.append((table_id, total_similarity)) # Sort by similarity score (asc), then by table_id (asc) scored_candidates.sort(key=lambda x: (x[1], x[0])) best_table = scored_candidates[0][0] return f"第{best_table}桌" def get_tablemates(cur, location: str, exclude_id: str) -> List[Dict]: """ Get up to 3 random tablemates from the same table location. """ if not location or location == "自由席": return [] # Debug: Check who is at this location print(f"DEBUG: Fetching tablemates for location: '{location}', excluding: '{exclude_id}'") # Important: Ensure the location string format matches database exactly # Database seems to store "第X桌", ensuring consistent querying # Updated query to fetch more details from checkin_info query = """ SELECT ci.name, gd.industry_company, ci.company_name, ci.position, ci.business_scope, ci.vision_2026 FROM checkin_info ci LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id WHERE ci.location = %s AND ci.gsdh_id != %s ORDER BY RANDOM() LIMIT 3 """ cur.execute(query, (location, exclude_id)) rows = cur.fetchall() print(f"DEBUG: Found {len(rows)} tablemates") tablemates = [] for row in rows: tablemates.append({ "name": row[0], "industry": row[1] or "暂无行业信息", "company_name": row[2] or "暂无单位信息", "position": row[3] or "暂无职务信息", "business_scope": row[4] or "暂无业务信息", "vision_2026": row[5] or "暂无愿景信息" }) return tablemates @app.get("/", response_class=HTMLResponse) async def read_root(request: Request): return templates.TemplateResponse("index.html", {"request": request}) @app.get("/api/search") async def search_user(query: str): """ Search user by phone (exact match) or name (fuzzy match). """ print(f"DEBUG: Searching for query: {query}") try: conn = get_db_connection() cur = conn.cursor(cursor_factory=RealDictCursor) # Priority 1: Exact Phone Match cur.execute("SELECT * FROM gsdh_data WHERE phone = %s", (query,)) user = cur.fetchone() # Priority 2: Fuzzy Name Match if not found by phone if not user: # Using ILIKE for case-insensitive fuzzy search cur.execute("SELECT * FROM gsdh_data WHERE name ILIKE %s", (f"%{query}%",)) users = cur.fetchall() if len(users) == 0: conn.close() return JSONResponse(content={"found": False, "message": "未查询到相关信息,请检查输入是否正确"}, status_code=404) elif len(users) > 1: # If multiple users found by name, return list for user to select (simplified here to return first or error) # For this MVP, let's return all matching users so frontend can handle selection conn.close() return JSONResponse(content={"found": True, "multiple": True, "users": users}) else: user = users[0] # Check if already signed if user.get('is_signed') == 'TRUE': # If already signed, fetch their assigned seat and tablemates cur.execute("SELECT location FROM checkin_info WHERE gsdh_id = %s", (user['new_id'],)) checkin_info = cur.fetchone() assigned_seat = checkin_info['location'] if checkin_info else "自由席" # Fetch tablemates # Use a new cursor for the helper function to avoid cursor factory conflict or state issues # Note: get_tablemates expects a standard cursor for tuple results, but here we have DictCursor # We can adapt get_tablemates or just use key access if we pass the DictCursor # Let's create a fresh standard cursor to be safe and consistent with get_tablemates implementation cur_plain = conn.cursor() tablemates = get_tablemates(cur_plain, assigned_seat, user['new_id']) cur_plain.close() conn.close() return JSONResponse(content={ "found": True, "user": user, "already_signed": True, "seat": assigned_seat, "tablemates": tablemates }) conn.close() return JSONResponse(content={"found": True, "user": user, "already_signed": False}) except Exception as e: return JSONResponse(content={"error": str(e)}, status_code=500) @app.post("/api/checkin") async def checkin_user(checkin_data: CheckinRequest): try: conn = get_db_connection() cur = conn.cursor() # 0. Get user's industry from gsdh_data to help with seat allocation cur.execute("SELECT industry_company FROM gsdh_data WHERE new_id = %s", (checkin_data.gsdh_id,)) res = cur.fetchone() user_industry = res[0] if res else "" # 1. Allocate Seat assigned_seat = assign_seat(cur, user_industry) # 2. Insert into checkin_info with assigned seat insert_sql = """ INSERT INTO checkin_info (name, phone, company_name, position, business_scope, vision_2026, location, gsdh_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """ cur.execute(insert_sql, ( checkin_data.name, checkin_data.phone, checkin_data.company_name, checkin_data.position, checkin_data.business_scope, checkin_data.vision_2026, assigned_seat, # Use the generated seat checkin_data.gsdh_id )) # 3. Update gsdh_data is_signed to TRUE update_sql = "UPDATE gsdh_data SET is_signed = 'TRUE' WHERE new_id = %s" cur.execute(update_sql, (checkin_data.gsdh_id,)) conn.commit() # 4. Fetch tablemates for the newly assigned seat tablemates = get_tablemates(cur, assigned_seat, checkin_data.gsdh_id) cur.close() conn.close() return {"success": True, "message": "签到成功!", "seat": assigned_seat, "tablemates": tablemates} except Exception as e: if 'conn' in locals(): conn.rollback() return JSONResponse(content={"success": False, "message": f"签到失败: {str(e)}"}, status_code=500) @app.get("/add-user", response_class=HTMLResponse) async def add_user_page(request: Request): secret = os.getenv("ADD_USER_SECRET", "123quant-speed") print(f"DEBUG: Secret loaded: '{secret}'") return templates.TemplateResponse("add_user.html", {"request": request, "secret": secret}) @app.post("/api/add-user") async def add_user_api(user_data: AddUserRequest): try: conn = get_db_connection() cur = conn.cursor() # Check if phone already exists cur.execute("SELECT * FROM gsdh_data WHERE phone = %s", (user_data.phone,)) if cur.fetchone(): conn.close() return JSONResponse(content={"success": False, "message": "该手机号已存在"}, status_code=400) # Calculate next new_id cur.execute("SELECT MAX(CAST(new_id AS INTEGER)) FROM gsdh_data WHERE new_id ~ '^[0-9]+$'") row = cur.fetchone() max_id = row[0] if row and row[0] is not None else 0 new_id = str(max_id + 1) insert_sql = """ INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed) VALUES (%s, %s, %s, %s, %s, %s, 'FALSE') """ cur.execute(insert_sql, ( new_id, user_data.name, user_data.phone, user_data.industry_company, user_data.fee, user_data.payment_channel )) conn.commit() cur.close() conn.close() return {"success": True, "message": "添加成功", "new_id": new_id} except Exception as e: if 'conn' in locals(): conn.rollback() return JSONResponse(content={"success": False, "message": f"添加失败: {str(e)}"}, status_code=500) if __name__ == "__main__": import uvicorn import argparse parser = argparse.ArgumentParser(description='Run the Checkin System.') parser.add_argument('--port', type=int, default=8800, help='Port to run the server on') args = parser.parse_args() uvicorn.run(app, host="0.0.0.0", port=args.port)