diff --git a/VERSION b/VERSION index 0b1f1ed..f9d405b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.13 +VERSION=1.2.14 diff --git a/backend/app/main.py b/backend/app/main.py index 3dc746d..7624c39 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from app.middleware.logging import logging_middleware from app.routers import auth, collections, operations from app.routers import ocr as ocr_router from app.routers import users as users_router +from app.routers import information as information_router # 版本信息 - 从 config/VERSION 文件读取 def get_version(): @@ -79,6 +80,7 @@ app.include_router(operations.router) app.include_router(ocr_router.router) # OCR 识别 app.include_router(users_router.router) # 当前用户接口 app.include_router(users_router.admin_router) # 管理员用户管理 +app.include_router(information_router.router) # 资讯 @app.get("/") diff --git a/backend/app/models/models.py b/backend/app/models/models.py index f010629..1794bb8 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,5 +1,5 @@ # 数据库模型 - 使用字段编码 -from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey, Date +from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey, Date, UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.core.database import Base @@ -146,3 +146,62 @@ class CustomField(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) user = relationship("User", back_populates="custom_fields") + + +# 资讯模型 +class Information(Base): + __tablename__ = "information" + + id = Column(String(36), primary_key=True, default=generate_uuid) + user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + + # 信息类型: seek-寻配号, deal-成交数据, publish-发布 + info_type = Column(String(20), nullable=False, index=True) + + # 标题 + title = Column(String(255), nullable=False) + + # 内容描述 + content = Column(Text, nullable=True) + + # 关联藏品ID + collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="SET NULL"), nullable=True) + + # 期望条件 (寻配号用) + expect_category = Column(String(100), nullable=True) # 期望类别 + expect_version = Column(String(100), nullable=True) # 期望版别 + expect_packaging = Column(String(100), nullable=True) # 期望包装 + expect_number = Column(String(50), nullable=True) # 期望号码 + expect_price_min = Column(Float, nullable=True) # 期望价格区间 + expect_price_max = Column(Float, nullable=True) + + # 成交价格 (成交数据用) + deal_price = Column(Float, nullable=True) + deal_date = Column(Date, nullable=True) + + # 状态: active-有效, closed-已关闭, expired-已过期 + status = Column(String(20), default="active", index=True) + + # 浏览/联系次数 + view_count = Column(Integer, default=0) + contact_count = Column(Integer, default=0) + + created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + user = relationship("User") + collection = relationship("Collection") + + +# 资讯关联用户 (收藏/点赞) +class InformationLike(Base): + __tablename__ = "information_likes" + + id = Column(String(36), primary_key=True, default=generate_uuid) + information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + UniqueConstraint('information_id', 'user_id', name='uq_information_user'), + ) diff --git a/backend/app/routers/information.py b/backend/app/routers/information.py new file mode 100644 index 0000000..ea7771b --- /dev/null +++ b/backend/app/routers/information.py @@ -0,0 +1,496 @@ +# 资讯API路由 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime, date + +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.models import User, Information, Collection + +router = APIRouter(prefix="/api/information", tags=["资讯"]) + + +# Schema +class InformationCreate(BaseModel): + info_type: str # seek-寻配号, deal-成交数据, publish-发布 + title: str + content: Optional[str] = None + collection_id: Optional[str] = None + expect_category: Optional[str] = None + expect_version: Optional[str] = None + expect_packaging: Optional[str] = None + expect_number: Optional[str] = None + expect_price_min: Optional[float] = None + expect_price_max: Optional[float] = None + deal_price: Optional[float] = None + deal_date: Optional[date] = None + + +class InformationUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + status: Optional[str] = None + expect_category: Optional[str] = None + expect_version: Optional[str] = None + expect_packaging: Optional[str] = None + expect_number: Optional[str] = None + expect_price_min: Optional[float] = None + expect_price_max: Optional[float] = None + deal_price: Optional[float] = None + deal_date: Optional[date] = None + + +class InformationResponse(BaseModel): + id: str + user_id: str + info_type: str + title: str + content: Optional[str] + collection_id: Optional[str] + expect_category: Optional[str] + expect_version: Optional[str] + expect_packaging: Optional[str] + expect_number: Optional[str] + expect_price_min: Optional[float] + expect_price_max: Optional[float] + deal_price: Optional[float] + deal_date: Optional[date] + status: str + view_count: int + contact_count: int + created_at: datetime + # 用户信息 + user_name: Optional[str] = None + user_avatar: Optional[str] = None + # 关联藏品信息 + collection_name: Optional[str] = None + collection_category: Optional[str] = None + collection_version: Optional[str] = None + collection_number: Optional[str] = None + + class Config: + from_attributes = True + + +# 资讯列表 +@router.get("/list", response_model=List[InformationResponse]) +def get_information_list( + info_type: Optional[str] = Query(None, description="类型: seek/deal/publish"), + status: str = Query("active", description="状态: active/closed/expired"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """获取资讯列表""" + query = db.query(Information).options( + joinedload(Information.user), + joinedload(Information.collection) + ).filter(Information.status == status) + + if info_type: + query = query.filter(Information.info_type == info_type) + + # 按创建时间倒序 + query = query.order_by(Information.created_at.desc()) + + # 分页 + offset = (page - 1) * page_size + items = query.offset(offset).limit(page_size).all() + + # 转换结果 + result = [] + for item in items: + result.append(InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=item.user.f01_01_name if item.user else None, + user_avatar=item.user.avatar if item.user else None, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + )) + + return result + + +# 获取单条资讯 +@router.get("/{info_id}", response_model=InformationResponse) +def get_information( + info_id: str, + db: Session = Depends(get_db) +): + """获取资讯详情""" + item = db.query(Information).options( + joinedload(Information.user), + joinedload(Information.collection) + ).filter(Information.id == info_id).first() + + if not item: + raise HTTPException(status_code=404, detail="资讯不存在") + + # 增加浏览次数 + item.view_count += 1 + db.commit() + + return InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=item.user.f01_01_name if item.user else None, + user_avatar=item.user.avatar if item.user else None, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + ) + + +# 发布资讯 +@router.post("/", response_model=InformationResponse) +def create_information( + data: InformationCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """发布资讯""" + info = Information( + user_id=current_user.f99_90_id, + info_type=data.info_type, + title=data.title, + content=data.content, + collection_id=data.collection_id, + expect_category=data.expect_category, + expect_version=data.expect_version, + expect_packaging=data.expect_packaging, + expect_number=data.expect_number, + expect_price_min=data.expect_price_min, + expect_price_max=data.expect_price_max, + deal_price=data.deal_price, + deal_date=data.deal_date, + status="active" + ) + db.add(info) + db.commit() + db.refresh(info) + + return InformationResponse( + id=info.id, + user_id=info.user_id, + info_type=info.info_type, + title=info.title, + content=info.content, + collection_id=info.collection_id, + expect_category=info.expect_category, + expect_version=info.expect_version, + expect_packaging=info.expect_packaging, + expect_number=info.expect_number, + expect_price_min=info.expect_price_min, + expect_price_max=info.expect_price_max, + deal_price=info.deal_price, + deal_date=info.deal_date, + status=info.status, + view_count=info.view_count, + contact_count=info.contact_count, + created_at=info.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=None, + collection_category=None, + collection_version=None, + collection_number=None, + ) + + +# 更新资讯 +@router.put("/{info_id}", response_model=InformationResponse) +def update_information( + info_id: str, + data: InformationUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新资讯""" + info = db.query(Information).filter( + Information.id == info_id, + Information.user_id == current_user.f99_90_id + ).first() + + if not info: + raise HTTPException(status_code=404, detail="资讯不存在或无权修改") + + # 更新字段 + if data.title is not None: + info.title = data.title + if data.content is not None: + info.content = data.content + if data.status is not None: + info.status = data.status + if data.expect_category is not None: + info.expect_category = data.expect_category + if data.expect_version is not None: + info.expect_version = data.expect_version + if data.expect_packaging is not None: + info.expect_packaging = data.expect_packaging + if data.expect_number is not None: + info.expect_number = data.expect_number + if data.expect_price_min is not None: + info.expect_price_min = data.expect_price_min + if data.expect_price_max is not None: + info.expect_price_max = data.expect_price_max + if data.deal_price is not None: + info.deal_price = data.deal_price + if data.deal_date is not None: + info.deal_date = data.deal_date + + db.commit() + db.refresh(info) + + return InformationResponse( + id=info.id, + user_id=info.user_id, + info_type=info.info_type, + title=info.title, + content=info.content, + collection_id=info.collection_id, + expect_category=info.expect_category, + expect_version=info.expect_version, + expect_packaging=info.expect_packaging, + expect_number=info.expect_number, + expect_price_min=info.expect_price_min, + expect_price_max=info.expect_price_max, + deal_price=info.deal_price, + deal_date=info.deal_date, + status=info.status, + view_count=info.view_count, + contact_count=info.contact_count, + created_at=info.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=info.collection.f01_01_name if info.collection else None, + collection_category=info.collection.f01_03_category if info.collection else None, + collection_version=info.collection.f02_11_version if info.collection else None, + collection_number=info.collection.f02_10_prefix_serial if info.collection else None, + ) + + +# 删除资讯 +@router.delete("/{info_id}") +def delete_information( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除资讯""" + info = db.query(Information).filter( + Information.id == info_id, + Information.user_id == current_user.f99_90_id + ).first() + + if not info: + raise HTTPException(status_code=404, detail="资讯不存在或无权删除") + + db.delete(info) + db.commit() + + return {"message": "删除成功"} + + +# 寻配号 - 自动匹配推荐藏品 +@router.get("/seek/match") +def get_seek_match( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取符合条件的我的藏品推荐""" + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 查询当前用户的藏品,匹配条件 + query = db.query(Collection).filter( + Collection.f99_91_user_id == current_user.f99_90_id, + Collection.f01_04_status == "in_collection" + ) + + if info.expect_category: + query = query.filter(Collection.f01_03_category == info.expect_category) + if info.expect_version: + query = query.filter(Collection.f02_11_version == info.expect_version) + if info.expect_packaging: + query = query.filter(Collection.f02_12_packaging == info.expect_packaging) + if info.expect_number: + query = query.filter(Collection.f02_10_prefix_serial.contains(info.expect_number)) + if info.expect_price_min: + query = query.filter(Collection.f05_40_cost_price >= info.expect_price_min) + if info.expect_price_max: + query = query.filter(Collection.f05_40_cost_price <= info.expect_price_max) + + matched_collections = query.all() + + return { + "info_id": info_id, + "matched_count": len(matched_collections), + "collections": [ + { + "id": c.f99_90_id, + "name": c.f01_01_name, + "category": c.f01_03_category, + "version": c.f02_11_version, + "packaging": c.f02_12_packaging, + "number": c.f02_10_prefix_serial, + "cost_price": c.f05_40_cost_price, + } + for c in matched_collections + ] + } + + +# 成交数据统计 +@router.get("/deal/stats") +def get_deal_stats( + days: int = Query(7, ge=1, le=90, description="统计天数"), + db: Session = Depends(get_db) +): + """获取成交数据统计""" + from sqlalchemy import func + from datetime import timedelta + + start_date = datetime.now() - timedelta(days=days) + + # 按版别统计 + by_version = db.query( + Information.expect_version, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price"), + func.max(Information.deal_price).label("max_price"), + func.min(Information.deal_price).label("min_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.created_at >= start_date + ).group_by(Information.expect_version).all() + + # 按包装统计 + by_packaging = db.query( + Information.expect_packaging, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.created_at >= start_date + ).group_by(Information.expect_packaging).all() + + # 按号码分类统计 + by_number = db.query( + Information.expect_number, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.expect_number.isnot(None), + Information.created_at >= start_date + ).group_by(Information.expect_number).all() + + return { + "days": days, + "by_version": [ + {"version": r[0] or "未知", "count": r[1], "avg_price": float(r[2] or 0), "max_price": float(r[3] or 0), "min_price": float(r[4] or 0)} + for r in by_version if r[0] + ], + "by_packaging": [ + {"packaging": r[0] or "未知", "count": r[1], "avg_price": float(r[2] or 0)} + for r in by_packaging if r[0] + ], + "by_number": [ + {"number": r[0], "count": r[1], "avg_price": float(r[2] or 0)} + for r in by_number + ] + } + + +# 获取我的发布列表 +@router.get("/my/list", response_model=List[InformationResponse]) +def get_my_information_list( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取我的发布列表""" + items = db.query(Information).options( + joinedload(Information.collection) + ).filter( + Information.user_id == current_user.f99_90_id + ).order_by(Information.created_at.desc()).offset((page-1)*page_size).limit(page_size).all() + + result = [] + for item in items: + result.append(InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + )) + + return result diff --git a/config/VERSION b/config/VERSION index 0b1f1ed..f9d405b 100644 --- a/config/VERSION +++ b/config/VERSION @@ -1 +1 @@ -1.2.13 +VERSION=1.2.14 diff --git a/frontend/index.html b/frontend/index.html index 3b9b15b..569b6ed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ -
{item.content}
+{item.content}
+ )} + + {/* 寻配号条件 */} + {item.info_type === 'seek' && (item.expect_category || item.expect_version || item.expect_number) && ( +