Files
check_in/main.py
jeremygan2021 7a716e7f5e t
2026-01-10 00:43:54 +08:00

350 lines
13 KiB
Python

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)