This commit is contained in:
jeremygan2021
2026-01-10 01:38:34 +08:00
parent 7a716e7f5e
commit 20c280405a
5 changed files with 843 additions and 41 deletions

View File

@@ -0,0 +1,45 @@
# 构建专家规则匹配系统 (Expert Rule-Based Matching)
既然不使用 AI 模型,要解决“尽快上市”和“证券”这种字面不同但意义相关的匹配,唯一且最高效的方法是构建一个**领域同义词库Domain Thesaurus**。这是一种经典的“专家系统”算法,通过预定义的业务规则来模拟理解。
## 1. 核心思路
我们不再依赖通用的模糊匹配,而是建立一套**精准的映射规则**。
- **原理**:计算机不知道“上市”需要“证券”,但我们知道。我们将这些业务逻辑“硬编码”为一本字典。
- **算法**`关键词提取` + `同义词扩散` + `加权匹配`
## 2. 方案优势
- **完全可控**:为什么匹配?为什么不匹配?逻辑一目了然。
- **零依赖**:不需要安装任何新库(如 torch 或 jieba纯 Python 原生代码,运行极快。
- **即刻生效**:只要规则里写了,匹配准确率就是 100%。
## 3. 实施计划
### 步骤 1: 构建商业同义词库 (Thesaurus)
`main.py` 中定义一个详尽的字典,覆盖主要的商业场景。
针对您的痛点,我会重点编写“上市/金融”类的规则:
```python
BUSINESS_THESAURUS = {
# 核心词: [同义词, 关联行业, 关联职能]
"上市": ["IPO", "证券", "股票", "股份", "路演", "投行", "辅导", "财报", "合规", "董秘"],
"证券": ["上市", "交易", "股票", "投资", "金融", "资本"],
"融资": ["找钱", "资金", "投资", "VC", "PE", "天使", "风投", "路演", "BP"],
"获客": ["销售", "渠道", "推广", "流量", "代理", "分销", "增长"],
"技术": ["研发", "代码", "程序", "系统", "平台", "App", "小程序", "AI"],
"法律": ["合规", "律师", "法务", "合同", "知识产权", "维权"],
"财税": ["会计", "审计", "报税", "记账", "财务"],
}
```
### 步骤 2: 实现智能匹配算法 (Smart Matching Algorithm)
我将编写一个 `compute_expert_score(text_a, text_b)` 函数:
1. **关键词扫描**:遍历字典的 `Key`,看 `text_a`(如愿景)中包含哪些核心词(如发现“上市”)。
2. **关联扩散**:如果发现了“上市”,不仅匹配“上市”本身,还自动去 `text_b`(如对方行业)中寻找 `Value` 列表中的词(如“证券”、“投行”)。
3. **加权打分**
- 直接命中关键词100分
- 命中关联词80分
- 保留原有的 `difflib` 作为兜底(处理人名或未收录的词)。
### 步骤 3: 替换现有逻辑
修改 `/api/resource-match``get_tablemates`,使用新的专家算法替换旧算法。
这个方案完全满足“不用AI”且“提高模糊语义准确度”的需求。

View File

@@ -1,8 +1,10 @@
# 使用 DaoCloud 国内镜像代理加速下载 (支持多架构) # 使用 DaoCloud 国内镜像代理加速下载 (支持多架构)
# 接收构建参数 BASE_IMAGE由 docker_deply.sh 传入 # 接收构建参数 BASE_IMAGE由 docker_deply.sh 传入
ARG BASE_IMAGE=python:3.9-slim # ARG BASE_IMAGE=python:3.9-slim
FROM ${BASE_IMAGE} # FROM ${BASE_IMAGE}
# FROM docker.m.daocloud.io/python:3.9-slim
# arm64 架构镜像
FROM docker.m.daocloud.io/python:3.9-slim
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app

Binary file not shown.

452
main.py
View File

@@ -4,6 +4,7 @@ from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
import psycopg2 import psycopg2
from psycopg2 import pool
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from typing import Optional, List, Dict from typing import Optional, List, Dict
import random import random
@@ -25,6 +26,89 @@ DB_CONFIG = {
"database": os.getenv("DB_NAME", "gsdh") "database": os.getenv("DB_NAME", "gsdh")
} }
# 商业领域同义词库 (Business Thesaurus) - 用于解决模糊语义匹配
BUSINESS_THESAURUS = {
# 核心意图: [关联行业/关键词列表]
"上市": ["IPO", "证券", "股票", "股份", "路演", "投行", "辅导", "财报", "合规", "董秘", "财务顾问", "审计", "律所", "金融", "机构"],
"证券": ["上市", "交易", "股票", "投资", "金融", "资本", "券商", "投行"],
"融资": ["找钱", "搞钱", "资金", "投资", "VC", "PE", "天使", "风投", "路演", "BP", "基金", "银行", "贷款"],
"资金": ["融资", "投资", "银行", "贷款", "过桥", "保理", "供应链金融"],
"获客": ["销售", "渠道", "推广", "流量", "代理", "分销", "增长", "营销", "广告", "传媒", "品牌", "私域"],
"销售": ["获客", "渠道", "代理", "分销", "带货", "电商", "直播"],
"技术": ["研发", "代码", "程序", "系统", "平台", "App", "小程序", "AI", "智能", "软件", "SaaS", "数字化", "算法", "架构"],
"法律": ["合规", "律师", "法务", "合同", "知识产权", "维权", "纠纷", "仲裁", "数据合规"],
"财税": ["会计", "审计", "报税", "记账", "财务", "税务", "节税"],
"出海": ["跨境", "外贸", "物流", "海外", "国际", "通关", "Tiktok", "多语言", "本地化"],
"供应链": ["物流", "仓储", "采购", "原材料", "制造", "工厂", "代工", "OEM"],
"人力": ["招聘", "猎头", "培训", "HR", "劳务", "派遣", "灵活用工"],
# AI 行业专项扩展
"AI": ["大模型", "算法", "算力", "芯片", "数据", "数字人", "机器人", "智能", "自动化", "Agent", "RAG", "AIGC"],
"大模型": ["OpenAI", "GPT", "文心", "通义", "Llama", "微调", "训练", "部署", "推理", "Token", "向量", "Prompt", "提示词"],
"算力": ["GPU", "显卡", "英伟达", "H800", "4090", "服务器", "云计算", "智算中心", "租赁", "托管"],
"芯片": ["半导体", "集成电路", "英伟达", "华为昇腾", "寒武纪", "FPGA", "ASIC"],
"数据": ["标注", "清洗", "采集", "语料", "数据集", "版权", "向量数据库"],
"数字人": ["直播", "短视频", "IP", "形象", "克隆", "配音", "虚拟人", "元宇宙"],
"具身智能": ["机器人", "机械臂", "无人机", "自动驾驶", "传感器", "视觉", "雷达", "端侧模型"]
}
def compute_expert_score(text_a: str, text_b: str) -> float:
"""
计算两个文本的匹配度,结合了字符相似度和专家规则语义匹配。
"""
if not text_a or not text_b:
return 0.0
# 1. 基础字符相似度 (Base Character Similarity)
# difflib 计算最长公共子序列,处理 "软件开发" vs "软件工程" 这种字面相似
base_score = difflib.SequenceMatcher(None, text_a, text_b).ratio()
# 2. 语义增强 (Semantic Boost)
# 通过同义词库建立 "上市" <-> "证券" 这种非字面联系
semantic_boost = 0.0
# 归一化处理
str_a = str(text_a).strip()
str_b = str(text_b).strip()
found_match = False
# 检查 A 中的关键词是否匹配 B 中的关联词
for key, related_words in BUSINESS_THESAURUS.items():
if key in str_a:
# 如果 A 包含 "上市",检查 B 是否包含 ["证券", "投行"...]
for word in related_words:
if word in str_b:
semantic_boost = 0.6 # 给予显著加分
found_match = True
break
if found_match: break
# 双向检查:检查 B 中的关键词是否匹配 A 中的关联词
if not found_match:
for key, related_words in BUSINESS_THESAURUS.items():
if key in str_b:
for word in related_words:
if word in str_a:
semantic_boost = 0.6
found_match = True
break
if found_match: break
# 最终分数:基础分 + 语义分,上限 1.0
# 这样 "尽快上市" (A) vs "证券行业" (B):
# base_score ≈ 0
# semantic_boost = 0.6 (因为 "上市" -> "证券")
# total = 0.6 -> 属于高匹配
return min(base_score + semantic_boost, 1.0)
# Initialize Connection Pool
try:
postgreSQL_pool = psycopg2.pool.ThreadedConnectionPool(1, 20, **DB_CONFIG)
print("PostgreSQL connection pool created successfully")
except (Exception, psycopg2.DatabaseError) as error:
print("Error while connecting to PostgreSQL", error)
# Mount static files # Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -48,8 +132,24 @@ class AddUserRequest(BaseModel):
payment_channel: Optional[str] = None payment_channel: Optional[str] = None
def get_db_connection(): def get_db_connection():
conn = psycopg2.connect(**DB_CONFIG) try:
conn = postgreSQL_pool.getconn()
try:
with conn.cursor() as cur:
cur.execute('SELECT 1')
return conn return conn
except (psycopg2.OperationalError, psycopg2.InterfaceError):
# Connection is dead, remove it from pool and create a new one
postgreSQL_pool.putconn(conn, close=True)
return postgreSQL_pool.getconn()
except Exception as e:
# If pool is exhausted or DB is down
print(f"Error getting DB connection: {e}")
raise e
def release_db_connection(conn):
if conn:
postgreSQL_pool.putconn(conn)
def assign_seat(cur, user_industry: str) -> str: def assign_seat(cur, user_industry: str) -> str:
""" """
@@ -69,8 +169,15 @@ def assign_seat(cur, user_industry: str) -> str:
# Fetch current seating status # Fetch current seating status
# Uses aggregation for efficiency as requested # Uses aggregation for efficiency as requested
# We use array_agg to collect industries for the diversity check in one query # We use array_agg to collect industries for the diversity check in one query
# Update: Include business_scope from checkin_info for more detailed matching
# We concatenate industry_company (from gsdh_data) and business_scope (from checkin_info)
query = """ query = """
SELECT ci.location, COUNT(ci.gsdh_id), array_agg(gd.industry_company) SELECT
ci.location,
COUNT(ci.gsdh_id),
array_agg(
COALESCE(gd.industry_company, '') || ' ' || COALESCE(ci.business_scope, '')
)
FROM checkin_info ci FROM checkin_info ci
LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
WHERE ci.location IS NOT NULL AND ci.location LIKE '%' WHERE ci.location IS NOT NULL AND ci.location LIKE '%'
@@ -122,9 +229,9 @@ def assign_seat(cur, user_industry: str) -> str:
total_similarity = 0.0 total_similarity = 0.0
for existing_ind in stats['industries']: for existing_ind in stats['industries']:
if existing_ind: if existing_ind:
# Use difflib for fuzzy matching (0.0 to 1.0) # Use Expert Score for better semantic matching
# This helps understand "Natural Language" industries better than exact match # This helps understand "Natural Language" industries better than exact match
sim = difflib.SequenceMatcher(None, user_industry, existing_ind).ratio() sim = compute_expert_score(user_industry, existing_ind)
total_similarity += sim total_similarity += sim
scored_candidates.append((table_id, total_similarity)) scored_candidates.append((table_id, total_similarity))
@@ -136,9 +243,11 @@ def assign_seat(cur, user_industry: str) -> str:
return f"{best_table}" return f"{best_table}"
def get_tablemates(cur, location: str, exclude_id: str) -> List[Dict]: def get_tablemates(cur, location: str, exclude_id: str, user_vision: str = "", user_industry: str = "") -> List[Dict]:
""" """
Get up to 3 random tablemates from the same table location. Get 3 tablemates based on:
1. Vision similarity: Match tablemate's vision_2026 with user's vision_2026 (Find similar goals)
2. Supply-Demand match: Match tablemate's vision_2026 with user's industry (Find potential partners)
""" """
if not location or location == "自由席": if not location or location == "自由席":
return [] return []
@@ -146,41 +255,289 @@ def get_tablemates(cur, location: str, exclude_id: str) -> List[Dict]:
# Debug: Check who is at this location # Debug: Check who is at this location
print(f"DEBUG: Fetching tablemates for location: '{location}', excluding: '{exclude_id}'") 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 # Updated query to fetch more details from checkin_info
query = """ query = """
SELECT ci.name, gd.industry_company, ci.company_name, ci.position, ci.business_scope, ci.vision_2026 SELECT ci.name, gd.industry_company, ci.company_name, ci.position, ci.business_scope, ci.vision_2026
FROM checkin_info ci FROM checkin_info ci
LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
WHERE ci.location = %s AND ci.gsdh_id != %s WHERE ci.location = %s AND ci.gsdh_id != %s
ORDER BY RANDOM()
LIMIT 3
""" """
cur.execute(query, (location, exclude_id)) cur.execute(query, (location, exclude_id))
rows = cur.fetchall() rows = cur.fetchall()
print(f"DEBUG: Found {len(rows)} tablemates") print(f"DEBUG: Found {len(rows)} potential tablemates")
tablemates = [] candidates = []
for row in rows: for row in rows:
tablemates.append({ candidate = {
"name": row[0], "name": row[0],
"industry": row[1] or "暂无行业信息", "industry": row[1] or "暂无行业信息",
"company_name": row[2] or "暂无单位信息", "company_name": row[2] or "暂无单位信息",
"position": row[3] or "暂无职务信息", "position": row[3] or "暂无职务信息",
"business_scope": row[4] or "暂无业务信息", "business_scope": row[4] or "暂无业务信息",
"vision_2026": row[5] or "暂无愿景信息" "vision_2026": row[5] or "",
"match_type": [],
"score": 0.0
}
candidates.append(candidate)
if not candidates:
return []
# Scoring Logic
for cand in candidates:
cand_vision = cand["vision_2026"]
cand_industry = cand["industry"]
# 1. Vision Similarity (Find peers with similar goals)
if user_vision and cand_vision:
sim = compute_expert_score(user_vision, cand_vision)
# Weight this score
cand["score"] += sim * 1.0
if sim > 0.3: # Threshold for "similarity"
cand["match_type"].append("志同道合 (愿景相似)")
# 2. Cross Match: My Industry matches Their Vision (I can help them)
if user_industry and cand_vision:
sim = compute_expert_score(user_industry, cand_vision)
cand["score"] += sim * 1.5 # Give higher weight to potential business match
if sim > 0.3:
cand["match_type"].append("潜在合作 (您的行业匹配对方愿景)")
# 3. Cross Match: Their Industry matches My Vision (They can help me)
if user_vision and cand_industry:
sim = compute_expert_score(user_vision, cand_industry)
cand["score"] += sim * 1.5
if sim > 0.3:
cand["match_type"].append("潜在贵人 (对方行业匹配您的愿景)")
# Sort by score descending
candidates.sort(key=lambda x: x["score"], reverse=True)
# Take top 3
top_candidates = candidates[:3]
# Format output
result = []
for cand in top_candidates:
# If no specific match type, just say "同桌伙伴"
match_reason = " | ".join(cand["match_type"]) if cand["match_type"] else "同桌伙伴"
result.append({
"name": cand["name"],
"industry": cand["industry"],
"company_name": cand["company_name"],
"position": cand["position"],
"business_scope": cand["business_scope"],
"vision_2026": cand["vision_2026"] or "暂无愿景信息",
"match_reason": match_reason
}) })
return tablemates
return result
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def read_root(request: Request): async def read_root(request: Request):
return templates.TemplateResponse("index.html", {"request": request}) return templates.TemplateResponse("index.html", {"request": request})
class UnlockRequest(BaseModel):
my_phone: str
target_id: str
class ResourceMatchRequest(BaseModel):
phone: str
@app.get("/search", response_class=HTMLResponse)
async def resource_match_page(request: Request):
return templates.TemplateResponse("resource_match.html", {"request": request})
@app.post("/api/resource-match")
def resource_match(req: ResourceMatchRequest):
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# 1. Fetch current user
# Optimize: Only fetch necessary fields
# Points are now in checkin_info
cur.execute("""
SELECT gd.new_id, gd.name, gd.phone, ci.social_point as points,
gd.industry_company, ci.business_scope, ci.vision_2026
FROM gsdh_data gd
LEFT JOIN checkin_info ci ON gd.new_id = ci.gsdh_id
WHERE gd.phone = %s
""", (req.phone,))
user = cur.fetchone()
if not user:
cur.close()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": "用户不存在"}, status_code=404)
user_industry = f"{user['industry_company'] or ''} {user['business_scope'] or ''}".strip()
user_vision = user['vision_2026'] or ""
# 2. Fetch ALL other users (who have checked in)
# Performance Note: Fetching all rows is slow if N is large.
# But for N < 1000 it's acceptable. For larger N, we need vector search (e.g. pgvector).
# We limit the fields to reduce payload size.
cur.execute("""
SELECT gd.new_id, gd.name, gd.phone, gd.industry_company,
ci.company_name, ci.position, ci.business_scope, ci.vision_2026, ci.location
FROM checkin_info ci
JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
WHERE gd.new_id != %s
""", (user['new_id'],))
others = cur.fetchall()
cur.close()
release_db_connection(conn)
# 3. Calculate Matches (In-Memory Python)
# difflib.SequenceMatcher is O(N*M), running it 3 times for every user is expensive.
# We can optimize by pre-calculating and caching, or just doing it efficiently.
matches = {
"customers": [], # My Industry vs Their Vision
"partners": [], # My Vision vs Their Industry
"peers": [] # My Industry vs Their Industry
}
# Optimization: Pre-compile SequenceMatcher objects if possible, but ratio() needs both strings.
# We use a threshold to quickly filter obvious non-matches if we had embeddings.
# For now, we stick to string matching but handle None values gracefully.
for other in others:
# Handle potential None values safely
other_ind_comp = other.get('industry_company') or ''
other_bus_scope = other.get('business_scope') or ''
other_industry = f"{other_ind_comp} {other_bus_scope}".strip()
other_vision = other.get('vision_2026') or ""
# 3.1 Customers (They need me)
# My Industry (Supply) matches Their Vision (Demand)
if user_industry and other_vision:
# Quick length check optimization: if length difference is huge, ratio will be low
score = compute_expert_score(user_industry, other_vision)
if score > 0.2:
matches["customers"].append({**other, "score": score})
# 3.2 Partners (I need them)
# My Vision (Demand) matches Their Industry (Supply)
if user_vision and other_industry:
score = compute_expert_score(user_vision, other_industry)
if score > 0.2:
matches["partners"].append({**other, "score": score})
# 3.3 Peers (Same industry)
# My Industry matches Their Industry
if user_industry and other_industry:
score = compute_expert_score(user_industry, other_industry)
if score > 0.3:
matches["peers"].append({**other, "score": score})
# 4. Sort and Limit
for key in matches:
matches[key].sort(key=lambda x: x["score"], reverse=True)
# Limit to top 20 for display performance
matches[key] = matches[key][:20]
# Hide sensitive info by default
for p in matches[key]:
# Safe phone masking
p_phone = p.get('phone', '')
if len(p_phone) >= 7:
p['phone'] = p_phone[:3] + "****" + p_phone[-4:]
else:
p['phone'] = "****"
p['location'] = "???" # Hidden location
p['unlocked'] = False
# Clean up internal fields to reduce JSON size
# p.pop('new_id', None)
return {
"success": True,
"user": {
"name": user['name'],
"industry_company": user['industry_company'],
"points": user['points'] if user['points'] is not None else 0,
"phone": user['phone']
},
"matches": matches
}
except Exception as e:
import traceback
traceback.print_exc()
if 'conn' in locals() and conn:
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.post("/api/unlock-contact")
def unlock_contact(req: UnlockRequest):
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# 1. Check User Points
# Points are now in checkin_info, queried by gsdh_id (which we can get from phone via gsdh_data join)
cur.execute("""
SELECT ci.social_point as points
FROM checkin_info ci
JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
WHERE gd.phone = %s
""", (req.my_phone,))
res = cur.fetchone()
if not res:
cur.close()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": "用户未签到或不存在"}, status_code=404)
points = res['points'] if res['points'] is not None else 0
if points <= 0:
cur.close()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": "积分不足"}, status_code=400)
# 2. Deduct Point
# Update checkin_info using a subquery to map phone to gsdh_id
cur.execute("""
UPDATE checkin_info
SET social_point = social_point - 1
WHERE gsdh_id = (SELECT new_id FROM gsdh_data WHERE phone = %s)
""", (req.my_phone,))
# 3. Fetch Target Info
cur.execute("""
SELECT gd.phone, ci.location
FROM gsdh_data gd
LEFT JOIN checkin_info ci ON gd.new_id = ci.gsdh_id
WHERE gd.new_id = %s
""", (req.target_id,))
target = cur.fetchone()
conn.commit()
cur.close()
release_db_connection(conn)
return {
"success": True,
"remaining_points": points - 1,
"contact": {
"phone": target['phone'],
"location": target['location'] or "未分配座位"
}
}
except Exception as e:
if 'conn' in locals() and conn:
conn.rollback()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.get("/api/search") @app.get("/api/search")
async def search_user(query: str): def search_user(query: str):
""" """
Search user by phone (exact match) or name (fuzzy match). Search user by phone (exact match) or name (fuzzy match).
""" """
@@ -200,21 +557,24 @@ async def search_user(query: str):
users = cur.fetchall() users = cur.fetchall()
if len(users) == 0: if len(users) == 0:
conn.close() cur.close()
release_db_connection(conn)
return JSONResponse(content={"found": False, "message": "未查询到相关信息,请检查输入是否正确"}, status_code=404) return JSONResponse(content={"found": False, "message": "未查询到相关信息,请检查输入是否正确"}, status_code=404)
elif len(users) > 1: elif len(users) > 1:
# If multiple users found by name, return list for user to select (simplified here to return first or error) # 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 # For this MVP, let's return all matching users so frontend can handle selection
conn.close() cur.close()
release_db_connection(conn)
return JSONResponse(content={"found": True, "multiple": True, "users": users}) return JSONResponse(content={"found": True, "multiple": True, "users": users})
else: else:
user = users[0] user = users[0]
# Check if already signed # Check if already signed
if user.get('is_signed') == 'TRUE': if user.get('is_signed') == 'TRUE':
# If already signed, fetch their assigned seat and tablemates # Check if already signed
cur.execute("SELECT location FROM checkin_info WHERE gsdh_id = %s", (user['new_id'],)) cur.execute("SELECT location, vision_2026 FROM checkin_info WHERE gsdh_id = %s", (user['new_id'],))
checkin_info = cur.fetchone() checkin_info = cur.fetchone() # Fetch as RealDictRow
assigned_seat = checkin_info['location'] if checkin_info else "自由席" assigned_seat = checkin_info['location'] if checkin_info else "自由席"
# Fetch tablemates # Fetch tablemates
@@ -223,10 +583,17 @@ async def search_user(query: str):
# We can adapt get_tablemates or just use key access if we pass the 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 # Let's create a fresh standard cursor to be safe and consistent with get_tablemates implementation
cur_plain = conn.cursor() cur_plain = conn.cursor()
tablemates = get_tablemates(cur_plain, assigned_seat, user['new_id'])
# Fetch user's vision and industry for matching
user_vision = checkin_info.get('vision_2026', '') if checkin_info else ''
# user['industry_company'] is already available in user dict
user_industry = user.get('industry_company', '')
tablemates = get_tablemates(cur_plain, assigned_seat, user['new_id'], user_vision, user_industry)
cur_plain.close() cur_plain.close()
conn.close() cur.close()
release_db_connection(conn)
return JSONResponse(content={ return JSONResponse(content={
"found": True, "found": True,
"user": user, "user": user,
@@ -235,14 +602,17 @@ async def search_user(query: str):
"tablemates": tablemates "tablemates": tablemates
}) })
conn.close() cur.close()
release_db_connection(conn)
return JSONResponse(content={"found": True, "user": user, "already_signed": False}) return JSONResponse(content={"found": True, "user": user, "already_signed": False})
except Exception as e: except Exception as e:
if 'conn' in locals() and conn:
release_db_connection(conn)
return JSONResponse(content={"error": str(e)}, status_code=500) return JSONResponse(content={"error": str(e)}, status_code=500)
@app.post("/api/checkin") @app.post("/api/checkin")
async def checkin_user(checkin_data: CheckinRequest): def checkin_user(checkin_data: CheckinRequest):
try: try:
conn = get_db_connection() conn = get_db_connection()
cur = conn.cursor() cur = conn.cursor()
@@ -250,16 +620,20 @@ async def checkin_user(checkin_data: CheckinRequest):
# 0. Get user's industry from gsdh_data to help with seat allocation # 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,)) cur.execute("SELECT industry_company FROM gsdh_data WHERE new_id = %s", (checkin_data.gsdh_id,))
res = cur.fetchone() res = cur.fetchone()
user_industry = res[0] if res else "" base_industry = res[0] if res and res[0] else ""
# Combine base industry with the newly provided business_scope for better matching
user_industry_info = f"{base_industry} {checkin_data.business_scope or ''}".strip()
# 1. Allocate Seat # 1. Allocate Seat
assigned_seat = assign_seat(cur, user_industry) assigned_seat = assign_seat(cur, user_industry_info)
# 2. Insert into checkin_info with assigned seat # 2. Insert into checkin_info with assigned seat
# Initialize social_point to 5
insert_sql = """ insert_sql = """
INSERT INTO checkin_info INSERT INTO checkin_info
(name, phone, company_name, position, business_scope, vision_2026, location, gsdh_id) (name, phone, company_name, position, business_scope, vision_2026, location, gsdh_id, social_point)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 4)
""" """
cur.execute(insert_sql, ( cur.execute(insert_sql, (
checkin_data.name, checkin_data.name,
@@ -279,16 +653,18 @@ async def checkin_user(checkin_data: CheckinRequest):
conn.commit() conn.commit()
# 4. Fetch tablemates for the newly assigned seat # 4. Fetch tablemates for the newly assigned seat
tablemates = get_tablemates(cur, assigned_seat, checkin_data.gsdh_id) # Use provided vision and industry for matching
tablemates = get_tablemates(cur, assigned_seat, checkin_data.gsdh_id, checkin_data.vision_2026 or "", user_industry_info)
cur.close() cur.close()
conn.close() release_db_connection(conn)
return {"success": True, "message": "签到成功!", "seat": assigned_seat, "tablemates": tablemates} return {"success": True, "message": "签到成功!", "seat": assigned_seat, "tablemates": tablemates}
except Exception as e: except Exception as e:
if 'conn' in locals(): if 'conn' in locals() and conn:
conn.rollback() conn.rollback()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": f"签到失败: {str(e)}"}, status_code=500) return JSONResponse(content={"success": False, "message": f"签到失败: {str(e)}"}, status_code=500)
@app.get("/add-user", response_class=HTMLResponse) @app.get("/add-user", response_class=HTMLResponse)
@@ -298,7 +674,7 @@ async def add_user_page(request: Request):
return templates.TemplateResponse("add_user.html", {"request": request, "secret": secret}) return templates.TemplateResponse("add_user.html", {"request": request, "secret": secret})
@app.post("/api/add-user") @app.post("/api/add-user")
async def add_user_api(user_data: AddUserRequest): def add_user_api(user_data: AddUserRequest):
try: try:
conn = get_db_connection() conn = get_db_connection()
cur = conn.cursor() cur = conn.cursor()
@@ -306,7 +682,8 @@ async def add_user_api(user_data: AddUserRequest):
# Check if phone already exists # Check if phone already exists
cur.execute("SELECT * FROM gsdh_data WHERE phone = %s", (user_data.phone,)) cur.execute("SELECT * FROM gsdh_data WHERE phone = %s", (user_data.phone,))
if cur.fetchone(): if cur.fetchone():
conn.close() cur.close()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": "该手机号已存在"}, status_code=400) return JSONResponse(content={"success": False, "message": "该手机号已存在"}, status_code=400)
# Calculate next new_id # Calculate next new_id
@@ -330,12 +707,13 @@ async def add_user_api(user_data: AddUserRequest):
conn.commit() conn.commit()
cur.close() cur.close()
conn.close() release_db_connection(conn)
return {"success": True, "message": "添加成功", "new_id": new_id} return {"success": True, "message": "添加成功", "new_id": new_id}
except Exception as e: except Exception as e:
if 'conn' in locals(): if 'conn' in locals() and conn:
conn.rollback() conn.rollback()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": f"添加失败: {str(e)}"}, status_code=500) return JSONResponse(content={"success": False, "message": f"添加失败: {str(e)}"}, status_code=500)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,377 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>资源匹配 - 供需对接</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--primary-color: #00f2ff; /* Cyan from the AI sphere/text */
--secondary-color: #0066ff; /* Rich blue */
--bg-color: #050814; /* Deep space blue/black */
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
--text-color: #ffffff;
--text-muted: #b0c4de; /* Light blue-grey */
--accent-glow: rgba(0, 242, 255, 0.6);
}
* {
box-sizing: border-box;
/* margin: 0; padding: 0; - Tailwind resets this but good to keep in mind */
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
background-image:
radial-gradient(circle at 50% 0%, #1a3a75 0%, #050814 60%),
radial-gradient(circle at 85% 30%, rgba(0, 242, 255, 0.1) 0%, transparent 40%),
radial-gradient(circle at 15% 70%, rgba(0, 102, 255, 0.15) 0%, transparent 40%);
background-attachment: fixed;
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none;
z-index: -1;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
.header-img {
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 12px;
margin-bottom: 25px;
box-shadow: 0 0 20px rgba(0, 242, 255, 0.2);
border: 1px solid rgba(0, 242, 255, 0.3);
}
.glass-card {
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 242, 255, 0.2);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), inset 0 0 20px rgba(0, 242, 255, 0.05);
border-radius: 16px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
h1, h2, h3 {
color: #fff;
}
h1 {
background: linear-gradient(180deg, #ffffff 0%, #d0eaff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 30px rgba(0, 242, 255, 0.4);
}
.text-primary { color: var(--primary-color); }
.text-muted { color: var(--text-muted); }
/* Custom Input Style to match index.html */
.custom-input {
width: 100%;
padding: 14px 16px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 8px;
color: white;
font-size: 1rem;
outline: none;
transition: all 0.3s;
box-shadow: inset 0 2px 5px rgba(0,0,0,0.3);
}
.custom-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 15px rgba(0, 242, 255, 0.3), inset 0 2px 5px rgba(0,0,0,0.3);
background: rgba(0, 0, 0, 0.6);
}
/* Custom Button Style */
.custom-btn {
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
color: #fff;
border: none;
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
transition: all 0.3s;
}
.custom-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 242, 255, 0.5);
background: linear-gradient(90deg, #0040cc 0%, #00d0dd 100%);
}
.custom-btn:disabled {
background: #2a3b55;
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
}
/* Tab Buttons */
.tab-btn {
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s;
}
.tab-btn.active {
background: rgba(0, 242, 255, 0.15);
color: #fff;
border-color: var(--primary-color);
box-shadow: 0 0 15px rgba(0, 242, 255, 0.2);
}
/* Avatar */
.avatar-circle {
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
color: white;
box-shadow: 0 0 10px rgba(0, 242, 255, 0.3);
}
/* Tags */
.tag {
background: rgba(0, 242, 255, 0.1);
color: var(--primary-color);
border: 1px solid rgba(0, 242, 255, 0.2);
}
/* Mobile specific adjustments */
@media (max-width: 640px) {
.container { padding: 15px; }
h1 { font-size: 1.5rem; }
}
</style>
</head>
<body>
<div id="app" class="container pb-20">
<!-- Header Image -->
<img src="/static/image.jpg" alt="Header Image" class="header-img" onerror="this.style.display='none'">
<!-- Header Info -->
<div class="glass-card p-6 mb-6 text-center relative overflow-hidden">
<!-- Background Decoration -->
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-cyan-400"></div>
<h1 class="text-2xl font-bold mb-2 tracking-wide">资源雷达 📡</h1>
<p class="text-sm text-muted">发现全场潜在商机与合作伙伴</p>
<!-- User Stats -->
<div v-if="user" class="mt-6 flex items-center justify-between bg-black/20 rounded-lg p-4 border border-white/10">
<div class="flex items-center text-left">
<div class="avatar-circle w-10 h-10 rounded-full flex items-center justify-center mr-3 text-lg font-bold shrink-0">
<i class="fas fa-user"></i>
</div>
<div>
<div class="font-bold text-white" v-text="user.name"></div>
<div class="text-xs text-muted truncate max-w-[120px]" v-text="user.industry_company"></div>
</div>
</div>
<div class="text-right">
<div class="text-xs text-muted mb-1">社交点数</div>
<div class="text-xl font-bold text-yellow-300 drop-shadow-md"><span v-text="user.points"></span> 💎</div>
</div>
</div>
</div>
<!-- Login / Search Form -->
<div v-if="!user">
<div class="glass-card p-8">
<h2 class="text-xl font-bold mb-6 text-center border-b border-white/10 pb-4">开启资源匹配</h2>
<div class="mb-6">
<label class="block text-primary text-sm font-bold mb-3 pl-1">手机号</label>
<input v-model="phone" type="tel" class="custom-input" placeholder="请输入您的手机号">
</div>
<button @click="login" :disabled="loading" class="w-full custom-btn py-3 px-4 rounded-lg font-bold flex items-center justify-center">
<span v-if="loading"><i class="fas fa-spinner fa-spin mr-2"></i>匹配中...</span>
<span v-else>开始匹配</span>
</button>
</div>
</div>
<!-- Results -->
<div v-else class="space-y-6">
<!-- Category Tabs -->
<div class="flex space-x-3 overflow-x-auto pb-2 scrollbar-hide">
<button @click="activeTab = 'customers'" :class="['px-5 py-2 rounded-full text-sm font-bold whitespace-nowrap tab-btn', activeTab === 'customers' ? 'active' : '']">
全场客户 (<span v-text="matches.customers.length"></span>)
</button>
<button @click="activeTab = 'partners'" :class="['px-5 py-2 rounded-full text-sm font-bold whitespace-nowrap tab-btn', activeTab === 'partners' ? 'active' : '']">
合作伙伴 (<span v-text="matches.partners.length"></span>)
</button>
<button @click="activeTab = 'peers'" :class="['px-5 py-2 rounded-full text-sm font-bold whitespace-nowrap tab-btn', activeTab === 'peers' ? 'active' : '']">
同行 (<span v-text="matches.peers.length"></span>)
</button>
</div>
<!-- List -->
<div class="space-y-4">
<div v-if="matches[activeTab].length === 0" class="text-center py-16 text-muted glass-card">
<i class="fas fa-satellite text-4xl mb-4 opacity-50"></i>
<p>暂无匹配结果</p>
</div>
<div v-for="(person, index) in matches[activeTab]" :key="index" class="glass-card p-5 relative overflow-hidden group hover:border-cyan-400/50 transition-colors">
<!-- Match Badge -->
<div class="absolute top-0 right-0 bg-gradient-to-bl from-yellow-500/20 to-transparent text-yellow-300 text-xs px-3 py-1 rounded-bl-lg font-bold border-l border-b border-yellow-500/20">
匹配度 <span v-text="Math.round(person.score * 100)"></span>%
</div>
<div class="flex items-start mb-4">
<div class="avatar-circle w-12 h-12 rounded-full flex items-center justify-center mr-4 text-xl font-bold shrink-0">
<span v-text="person.name[0]"></span>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-lg text-white mb-1" v-text="person.name"></h3>
<p class="text-sm text-muted flex items-center truncate">
<i class="fas fa-building mr-2 opacity-70"></i>
<span v-text="person.company_name || '暂无公司信息'"></span>
</p>
<p class="text-xs text-muted/70 mt-1 pl-6 truncate" v-text="person.position || '职位未知'"></p>
</div>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
<span class="tag text-xs px-2 py-1 rounded flex items-center">
<i class="fas fa-briefcase mr-1 opacity-70"></i><span v-text="person.industry"></span>
</span>
<span class="tag text-xs px-2 py-1 rounded flex items-center">
<i class="fas fa-bullseye mr-1 opacity-70"></i>
<span class="truncate max-w-[200px]" v-text="person.vision_2026 || '暂无愿景'"></span>
</span>
</div>
<!-- Contact Action -->
<div class="border-t border-white/10 pt-4 mt-2">
<div v-if="person.unlocked" class="bg-green-900/20 border border-green-500/30 p-3 rounded-lg text-sm text-green-400">
<p class="mb-1"><i class="fas fa-phone mr-2"></i><span v-text="person.phone"></span></p>
<p><i class="fas fa-map-marker-alt mr-2"></i><span v-text="person.location"></span></p>
</div>
<button v-else @click="unlockContact(person)" class="w-full bg-white/5 hover:bg-white/10 text-white text-sm py-2 rounded-lg transition border border-white/10 flex items-center justify-center group-hover:border-cyan-400/30">
<i class="fas fa-lock mr-2 text-muted group-hover:text-cyan-400"></i> 查看联系方式 (-1 💎)
</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="toast.show" class="fixed bottom-10 left-1/2 transform -translate-x-1/2 bg-gray-900 border border-cyan-500/50 text-white px-6 py-3 rounded-lg shadow-[0_0_20px_rgba(0,242,255,0.3)] text-sm transition-opacity duration-300 z-50 flex items-center" :class="{'opacity-0': !toast.show}">
<i class="fas fa-info-circle text-primary mr-2"></i>
<span v-text="toast.message"></span>
</div>
</div>
<script>
const { createApp, ref, reactive } = Vue
createApp({
setup() {
const phone = ref('')
const user = ref(null)
const loading = ref(false)
const activeTab = ref('customers')
const matches = reactive({
customers: [],
partners: [],
peers: []
})
const toast = reactive({ show: false, message: '' })
const showToast = (msg) => {
toast.message = msg
toast.show = true
setTimeout(() => toast.show = false, 3000)
}
const login = async () => {
if (!phone.value) return showToast('请输入手机号')
loading.value = true
try {
const res = await fetch('/api/resource-match', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ phone: phone.value })
})
const data = await res.json()
if (data.success) {
user.value = data.user
matches.customers = data.matches.customers
matches.partners = data.matches.partners
matches.peers = data.matches.peers
} else {
showToast(data.message || '匹配失败')
}
} catch (e) {
showToast('网络错误')
} finally {
loading.value = false
}
}
const unlockContact = async (person) => {
if (user.value.points <= 0) {
return showToast('社交点数不足,请联系工作人员充值')
}
if (!confirm(`确定消耗1点数查看 ${person.name} 的联系方式吗?`)) return
try {
const res = await fetch('/api/unlock-contact', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
my_phone: user.value.phone,
target_id: person.new_id
})
})
const data = await res.json()
if (data.success) {
person.phone = data.contact.phone
person.location = data.contact.location
person.unlocked = true
user.value.points = data.remaining_points
showToast('解锁成功!')
} else {
showToast(data.message)
}
} catch (e) {
showToast('解锁失败')
}
}
return {
phone, user, loading, activeTab, matches, login, unlockContact, toast
}
}
}).mount('#app')
</script>
</body>
</html>