350 lines
13 KiB
Python
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)
|