diff --git a/backend/app/routers/seek.py b/backend/app/routers/seek.py index 255c7ae..93a2d81 100644 --- a/backend/app/routers/seek.py +++ b/backend/app/routers/seek.py @@ -1,3 +1,7 @@ +# seek - 寻配号路由 +# Version: 0.0.3 (2026-04-24) +# 更新:添加缺失端点,清理废弃依赖 + from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.orm import Session from pydantic import BaseModel @@ -6,8 +10,13 @@ from datetime import datetime from app.core.database import get_db from app.core.auth import get_current_user from app.models.seek_info import SeekInfo -from app.models.models import User, Collection, Information -from sqlalchemy import text +from app.models.models import User, Collection +from app.utils.coolbot_matcher import ( + match_pattern, + match_self_collections_count, + match_collections_count_from_coolbot, + match_collections_list_from_coolbot +) router = APIRouter(prefix="/api/seek", tags=["寻配号"]) @@ -59,6 +68,11 @@ class SeekInfoResponse(BaseModel): class Config: from_attributes = True +class MatchConfirmRequest(BaseModel): + info_id: str + contact: Optional[str] = None + collection_id: Optional[str] = None + # ============ API ============ @router.get("/list", response_model=list[SeekInfoResponse]) def get_seek_list( @@ -83,8 +97,6 @@ def get_seek_list( offset = (page - 1) * page_size items = query.offset(offset).limit(page_size).all() - # 添加用户名和匹配数量 - from app.routers.information import match_collections_count, match_collections_count_from_coolbot result = [] for item in items: user = db.query(User).filter(User.f99_90_id == item.user_id).first() @@ -95,7 +107,7 @@ def get_seek_list( network_matched_count = 0 if item.expect_number and len(item.expect_number) == 10: if current_user and current_user.f99_90_id: - matched_count = match_collections_count(db, current_user.f99_90_id, item.expect_number) + matched_count = match_self_collections_count(db, current_user.f99_90_id, item.expect_number) network_matched_count = match_collections_count_from_coolbot(item.expect_number) # 构建响应 @@ -232,7 +244,9 @@ def delete_seek( db.commit() return {"message": "删除成功"} -# 获取自有藏品匹配列表 + +# ============ 匹配相关API ============ + @router.get("/my-match") def get_seek_match( info_id: str, @@ -254,7 +268,6 @@ def get_seek_match( ).all() # 按号码特征模式匹配 - from app.routers.information import match_pattern matched = [] if info.expect_number and len(info.expect_number) == 10: pattern = info.expect_number[2:] @@ -280,7 +293,6 @@ def get_seek_match( ] } -# 获取网络数据匹配列表 @router.get("/network-match/{info_id}") def get_network_match( info_id: str, @@ -288,8 +300,6 @@ def get_network_match( db: Session = Depends(get_db) ): """获取一尘数据库中匹配的藏品列表""" - from app.routers.information import match_collections_list_from_coolbot - info = db.query(SeekInfo).filter(SeekInfo.id == info_id).first() if not info: raise HTTPException(status_code=404, detail="寻配号信息不存在") @@ -299,3 +309,107 @@ def get_network_match( matched = match_collections_list_from_coolbot(info.expect_number, limit=limit) return {"matched_count": len(matched), "collections": matched} + +@router.post("/match-confirm") +def match_seek_confirm( + request: MatchConfirmRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """确认匹配寻号 - 用户愿意交换联系方式给发布者""" + info = db.query(SeekInfo).filter( + SeekInfo.id == request.info_id, + SeekInfo.status == "active" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 检查是否已被匹配(一个寻号只能被一个用户匹配) + if info.is_matched == "matched": + raise HTTPException(status_code=400, detail="该寻号已被其他用户匹配") + + # 检查是否是自己发布的 + if info.user_id == current_user.f99_90_id: + raise HTTPException(status_code=400, detail="不能匹配自己发布的寻号") + + # 更新匹配状态 + info.is_matched = "matched" + info.matched_user_id = current_user.f99_90_id + info.matched_contact = request.contact or '' + + # 更新发布寻号者的内容,显示有藏品被匹配 + original_content = info.content or "" + match_info = f"\n藏品被{current_user.f01_01_name}匹配成功!联系方式:{request.collection_id or '已交换'}" + info.content = original_content + match_info + + db.commit() + + return {"message": "匹配成功,已通知发布者", "is_matched": "matched"} + +@router.get("/matched-user/{info_id}") +def get_matched_user( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻号的匹配者信息(仅发布者可见)""" + info = db.query(SeekInfo).filter(SeekInfo.id == info_id).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 只有发布者可以看到匹配者信息 + if info.user_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权访问") + + if not info.matched_user_id: + return {"message": "暂无匹配者"} + + # 获取匹配者信息 + matched_user = db.query(User).filter(User.f99_90_id == info.matched_user_id).first() + if not matched_user: + return {"message": "匹配者不存在"} + + return { + "matched_user_id": info.matched_user_id, + "user_name": matched_user.f01_01_name, + "phone": matched_user.phone, + "matched_contact": info.matched_contact, + } + +@router.get("/publisher/{info_id}") +def get_publisher_info( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻号的发布者信息(仅匹配者可见)""" + info = db.query(SeekInfo).filter(SeekInfo.id == info_id).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 只有匹配者可以看到发布者信息 + if not info.matched_user_id or info.matched_user_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权访问") + + # 获取发布者信息 + publisher = db.query(User).filter(User.f99_90_id == info.user_id).first() + if not publisher: + return {"message": "发布者不存在"} + + # 从content中解析联系方式 + contact = '' + if info.content: + import re + match = re.search(r'联系方式[:\s]*(.+?)(?:\n|$)', info.content) + if match: + contact = match.group(1).strip() + + return { + "user_id": info.user_id, + "user_name": publisher.f01_01_name, + "phone": publisher.phone, + "contact": contact, + } diff --git a/backend/app/utils/coolbot_matcher.py b/backend/app/utils/coolbot_matcher.py new file mode 100644 index 0000000..c435b76 --- /dev/null +++ b/backend/app/utils/coolbot_matcher.py @@ -0,0 +1,208 @@ +# coolbot_matcher - 一尘数据库号码匹配工具 +# Version: 0.0.1 (2026-04-24) +# 描述:从一尘数据库(coolbot_data)查询匹配的藏品号码 + +from typing import List +from sqlalchemy import text +from sqlalchemy.orm import Session +from app.core.coolbot_db import coolbot_engine + + +def match_pattern(col_number: str, pattern: str) -> bool: + """匹配号码特征模式 + + 通配符规则: + - X = 任意数字 + - A = 非4 + - B = 非47 + - C = 非347 + - D = 非247 + - E = 非2347 + """ + if not col_number or not pattern: + return False + + if len(col_number) != len(pattern): + return False + + for i, p in enumerate(pattern): + c = col_number[i] + if p == 'X': + continue # 任意数字 + elif p == 'A': + if c == '4': + return False + elif p == 'B': + if c == '4': + return False + if i < len(col_number) - 1 and col_number[i+1] == '7' and pattern[i+1] == 'X': + return False + elif p == 'C': + if c in '347': + return False + elif p == 'D': + if c in '247': + return False + elif p == 'E': + if c in '2347': + return False + elif p == 'L': + # L = 带4 + if c != '4': + return False + elif p == 'N': + # N = 无4 + if c == '4': + return False + else: + if c != p: + return False + return True + + +def match_self_collections_count(db: Session, user_id: str, expect_number: str) -> int: + """根据号码特征计算匹配藏品数量(从用户自有藏品) + + Args: + db: 数据库会话 + user_id: 用户ID + expect_number: 期望号码,如 "J012345678" (固定J0前缀+8位特征) + + Returns: + 匹配的藏品数量 + """ + from app.models.models import Collection + + if not expect_number or len(expect_number) != 10: + return 0 + + if not expect_number.startswith('J0'): + return 0 + + pattern = expect_number[2:] # 后8位 + if not pattern: + return 0 + + # 获取用户所有藏品 + collections = db.query(Collection).filter( + Collection.f99_91_user_id == user_id, + Collection.f01_04_status == "in_collection" + ).all() + + count = 0 + for c in collections: + number = c.f02_10_prefix_serial or '' + if len(number) >= 10 and number.startswith('J0'): + col_pattern = number[2:10] + if match_pattern(col_pattern, pattern): + count += 1 + elif len(number) >= 8: + col_pattern = number[:8] + if match_pattern(col_pattern, pattern): + count += 1 + + return count + + +def match_collections_count_from_coolbot(expect_number: str) -> int: + """根据号码特征计算匹配藏品数量(从coolbot_data数据库) + + Args: + expect_number: 期望号码,如 "J012345678" (固定J0前缀+8位特征) + + Returns: + 匹配的藏品数量 + """ + if not expect_number or len(expect_number) != 10: + return 0 + + if not expect_number.startswith('J0'): + return 0 + + pattern = expect_number[2:] # 后8位 + if not pattern: + return 0 + + query = text(""" + SELECT id, crown_code FROM collections + WHERE crown_code IS NOT NULL + AND crown_code != '' + AND LENGTH(crown_code) >= 10 + AND crown_code LIKE 'J0%' + """) + + try: + with coolbot_engine.connect() as conn: + result = conn.execute(query) + + match_count = 0 + for row in result: + crown_code = row[1] + if crown_code and len(crown_code) >= 10: + col_pattern = crown_code[2:10] + if match_pattern(col_pattern, pattern): + match_count += 1 + + return match_count + except Exception as e: + print(f"Error querying coolbot_data: {e}") + return 0 + + +def match_collections_list_from_coolbot(expect_number: str, limit: int = 20) -> List[dict]: + """获取匹配的藏品列表(从coolbot_data数据库) + + Args: + expect_number: 期望号码,如 "J012345678" + limit: 返回的最大数量 + + Returns: + 匹配的藏品列表 + """ + if not expect_number or len(expect_number) != 10: + return [] + + if not expect_number.startswith('J0'): + return [] + + pattern = expect_number[2:] # 后8位 + if not pattern: + return [] + + query = text(""" + SELECT id, name, category, crown_code, price, post_title, post_url, author, post_crawled_at + FROM collections + WHERE crown_code IS NOT NULL + AND crown_code != '' + AND LENGTH(crown_code) >= 10 + AND crown_code LIKE 'J0%' + """) + + try: + with coolbot_engine.connect() as conn: + result = conn.execute(query) + + matched = [] + for row in result: + crown_code = row[3] + if crown_code and len(crown_code) >= 10: + col_pattern = crown_code[2:10] + if match_pattern(col_pattern, pattern): + matched.append({ + "id": row[0], + "name": row[1], + "category": row[2], + "crown_code": crown_code, + "price": float(row[4]) if row[4] else None, + "post_title": row[5], + "post_url": row[6], + "author": row[7], + "post_crawled_at": row[8].isoformat() if row[8] else None + }) + if len(matched) >= limit: + break + + return matched + except Exception as e: + print(f"Error querying coolbot_data: {e}") + return [] diff --git a/frontend/src/pages/News.jsx b/frontend/src/pages/News.jsx index 8aaebae..b281867 100644 --- a/frontend/src/pages/News.jsx +++ b/frontend/src/pages/News.jsx @@ -1,7 +1,7 @@ /** * News - 资讯列表页面 - * Version: 0.0.1 - * 更新: + * Version: 0.0.2 (2026-04-24) + * 更新:迁移seek相关API到/api/seek/* */ import React, { useState, useEffect } from 'react' @@ -195,7 +195,7 @@ export default function News() { if (!token) { alert('请先登录'); return } try { const phone = getUserPhone() - const res = await fetch(`${API_BASE}/api/information/seek/match-confirm`, { + const res = await fetch(`${API_BASE}/api/seek/match-confirm`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ info_id: infoId, contact: phone || '用户已同意' }) @@ -232,7 +232,7 @@ export default function News() { if (!token) { alert('请先登录'); return } } try { - const res = await fetch(`${API_BASE}/api/information/seek/matched-user/${infoId}`, { headers: { Authorization: `Bearer ${token}` } }) + const res = await fetch(`${API_BASE}/api/seek/matched-user/${infoId}`, { headers: { Authorization: `Bearer ${token}` } }) console.log('Matched user response:', res.status) const data = await res.json() console.log('Matched user data:', data) @@ -245,7 +245,7 @@ export default function News() { const token = localStorage.getItem('token') if (!token) { alert('请先登录'); return } try { - const res = await fetch(`${API_BASE}/api/information/seek/publisher/${infoId}`, { headers: { Authorization: `Bearer ${token}` } }) + const res = await fetch(`${API_BASE}/api/seek/publisher/${infoId}`, { headers: { Authorization: `Bearer ${token}` } }) console.log('Publisher response:', res.status) const data = await res.json() console.log('Publisher data:', data) @@ -257,7 +257,7 @@ export default function News() { const token = localStorage.getItem('token') if (!token) { alert('请先登录'); return } try { - const res = await fetch(`${API_BASE}/api/information/seek/match?info_id=${infoId}`, { + const res = await fetch(`${API_BASE}/api/seek/my-match?info_id=${infoId}`, { headers: { Authorization: `Bearer ${token}` } }) const data = await res.json() @@ -331,7 +331,7 @@ export default function News() { try { // 解析正文中的号码特征和联系方式 const content = (editingSeek.content || '') + '\n联系方式: ' + editingSeek.contact - await fetch(`${API_BASE}/api/information/${editingSeek.id}`, { + await fetch(`${API_BASE}/api/seek/${editingSeek.id}`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -355,14 +355,13 @@ export default function News() { const token = localStorage.getItem('token') // 从edition映射到category const categoryMap = { '龙钞': '龙钞', '马钞': '马钞', '蛇钞': '蛇钞' } - const res = await fetch(`${API_BASE}/api/information/`, { + const res = await fetch(`${API_BASE}/api/seek/`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: seekForm.title, content, - info_type: 'seek', expect_category: seekForm.edition, - expect_number: seekForm.features ? 'J0' + seekForm.features : None + expect_number: seekForm.features ? 'J0' + seekForm.features : null }) }) const data = await res.json()