331 lines
12 KiB
Python
331 lines
12 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
|
|
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 (13 tables, max 12 per table)
|
|
2. Mix industries (try to put user in a table where their industry is least represented)
|
|
"""
|
|
TOTAL_TABLES = 13
|
|
MAX_PER_TABLE = 12
|
|
|
|
# Initialize table stats
|
|
# tables = { 1: {'count': 0, 'industries': []}, ... }
|
|
tables = {i: {'count': 0, 'industries': []} for i in range(1, TOTAL_TABLES + 1)}
|
|
|
|
# Fetch current seating status
|
|
# We join checkin_info with gsdh_data to get industries of people ALREADY SEATED
|
|
query = """
|
|
SELECT ci.location, gd.industry_company
|
|
FROM checkin_info ci
|
|
JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
|
|
WHERE ci.location IS NOT NULL AND ci.location LIKE '第%桌'
|
|
"""
|
|
cur.execute(query)
|
|
rows = cur.fetchall()
|
|
|
|
for row in rows:
|
|
loc = row[0] # e.g. "第1桌"
|
|
ind = row[1]
|
|
try:
|
|
# Extract table number
|
|
table_num = int(loc.replace("第", "").replace("桌", ""))
|
|
if 1 <= table_num <= TOTAL_TABLES:
|
|
tables[table_num]['count'] += 1
|
|
if ind:
|
|
tables[table_num]['industries'].append(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 (Even Distribution)
|
|
min_count = min(t[1]['count'] for t in available_tables)
|
|
candidates_step1 = [t for t in available_tables if t[1]['count'] == min_count]
|
|
|
|
# Strategy 2: Among candidates, find best for diversity
|
|
# We want a table where user_industry is NOT present, or present least often
|
|
best_table = None
|
|
|
|
# If user has no industry info, just pick random from candidates
|
|
if not user_industry:
|
|
best_table = random.choice(candidates_step1)[0]
|
|
else:
|
|
# Score candidates: lower score is better (score = count of this industry in that table)
|
|
scored_candidates = []
|
|
for table_id, stats in candidates_step1:
|
|
# Simple fuzzy check: count how many times user_industry appears in stats['industries']
|
|
# We use simple string containment
|
|
industry_count = sum(1 for existing_ind in stats['industries'] if existing_ind and user_industry in existing_ind)
|
|
scored_candidates.append((table_id, industry_count))
|
|
|
|
# Sort by industry count (asc)
|
|
scored_candidates.sort(key=lambda x: x[1])
|
|
|
|
# Pick the one with least collision
|
|
min_collision = scored_candidates[0][1]
|
|
final_candidates = [x[0] for x in scored_candidates if x[1] == min_collision]
|
|
best_table = random.choice(final_candidates)
|
|
|
|
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
|
|
uvicorn.run(app, host="0.0.0.0", port=8800)
|