diff --git a/backend/app/models/purchase.py b/backend/app/models/purchase.py index ef9d392..8b58bbb 100644 --- a/backend/app/models/purchase.py +++ b/backend/app/models/purchase.py @@ -1,8 +1,8 @@ # purchase - 认购群模型 -# Version: 0.0.1 (2026-04-24) +# Version: 0.0.2 (2026-04-29) # 认购群、成员、藏品管理 -from sqlalchemy import Column, String, Text, Integer, DECIMAL, DateTime, ForeignKey +from sqlalchemy import Column, String, Text, Integer, DECIMAL, DateTime, ForeignKey, Boolean from sqlalchemy.sql import func from app.core.database import Base import uuid @@ -15,14 +15,19 @@ class PurchaseGroup(Base): __tablename__ = "purchase_group" id = Column(String(36), primary_key=True, default=generate_uuid) + group_code = Column(String(20), nullable=False, unique=True) # 年+序号,如 26001 name = Column(String(100), nullable=False) # 认购群名称 description = Column(Text, nullable=True) # 描述 creator_id = Column(String(36), nullable=False) # 创建者ID creator_name = Column(String(50), nullable=True) # 创建者名字 status = Column(String(20), default="active") # active/closed total_members = Column(Integer, default=0) # 总参与人数 + total_pending = Column(Integer, default=0) # 待审批人数 total_collections = Column(Integer, default=0) # 总藏品数 + total_confirmed = Column(Integer, default=0) # 已确认数 total_amount = Column(DECIMAL(12,2), default=0) # 总金额 + cycle_months = Column(Integer, nullable=True) # 认购周期(月) + start_date = Column(DateTime(timezone=True), nullable=True) # 开始日期 avg_price = Column(DECIMAL(10,2), default=0) # 均价 created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -37,6 +42,13 @@ class PurchaseMember(Base): user_name = Column(String(50), nullable=True) user_avatar = Column(String(255), nullable=True) role = Column(String(20), default="member") # creator/admin/member + status = Column(String(20), default="pending") # pending/approved/rejected + # 成员扩展信息 + wechat_name = Column(String(50), nullable=True) # 微信名 + contact = Column(String(50), nullable=True) # 联系方式 + purchase_count = Column(Integer, default=0) # 认购数量 + paid_amount = Column(DECIMAL(12,2), default=0) # 已付认购款 + notes = Column(Text, nullable=True) # 备注 joined_at = Column(DateTime(timezone=True), server_default=func.now()) class PurchaseCollection(Base): @@ -48,8 +60,13 @@ class PurchaseCollection(Base): user_id = Column(String(36), nullable=False) user_name = Column(String(50), nullable=True) collection_name = Column(String(100), nullable=True) # 藏品名称 - collection_code = Column(String(50), nullable=True) # 藏品编号 - number = Column(String(50), nullable=True) # 冠字号 - price = Column(DECIMAL(10,2), nullable=False) # 认购价格 + collection_code = Column(String(50), nullable=True) # 藏品编号 (自动生成001开始) + number = Column(String(50), nullable=False) # 冠字号 (必填) + grading = Column(String(50), nullable=False) # 评级 (必填) + boss_name = Column(String(50), nullable=False) # 认购老板 (必填,从成员中选择) + price = Column(DECIMAL(10,2), nullable=False) # 认购价格 (必填) + source = Column(String(20), nullable=False) # 来源: yichen/live/friend/other (必填) + paid = Column(Boolean, default=False) # 认购款是否结清 + submit_date = Column(DateTime(timezone=True), server_default=func.now()) # 提交日期 status = Column(String(20), default="pending") # pending/confirmed submitted_at = Column(DateTime(timezone=True), server_default=func.now()) \ No newline at end of file diff --git a/backend/app/routers/purchase.py b/backend/app/routers/purchase.py index 1663f27..a5478da 100644 --- a/backend/app/routers/purchase.py +++ b/backend/app/routers/purchase.py @@ -1,5 +1,5 @@ # purchase - 认购群API路由 -# Version: 0.0.1 (2026-04-24) +# Version: 0.0.5 (2026-04-29) # 认购群、成员、藏品管理 from fastapi import APIRouter, Depends, HTTPException, Query @@ -16,27 +16,92 @@ router = APIRouter(prefix="/api/purchase", tags=["认购"]) # ============ Schema ============ class PurchaseGroupCreate(BaseModel): + cycle_months: Optional[int] = None + start_date: Optional[str] = None name: str description: Optional[str] = None class PurchaseGroupResponse(BaseModel): + cycle_months: Optional[int] = None + start_date: Optional[datetime] = None id: str + group_code: str name: str description: Optional[str] creator_id: str creator_name: Optional[str] status: str total_members: int + total_pending: int total_collections: int + total_confirmed: int total_amount: float avg_price: float created_at: Optional[datetime] class PurchaseCollectionCreate(BaseModel): collection_name: str - collection_code: Optional[str] = None - number: Optional[str] = None + number: str + grading: str + boss_name: str price: float + source: str + paid: bool = False + +class PurchaseCollectionUpdate(BaseModel): + collection_name: Optional[str] = None + number: Optional[str] = None + grading: Optional[str] = None + boss_name: Optional[str] = None + price: Optional[float] = None + source: Optional[str] = None + paid: Optional[bool] = None + status: Optional[str] = None + +class PurchaseCollectionResponse(BaseModel): + id: str + group_id: str + user_id: str + user_name: Optional[str] + collection_name: Optional[str] + collection_code: Optional[str] + number: str + grading: str + boss_name: str + price: float + source: str + paid: bool + status: str + submit_date: Optional[datetime] + submitted_at: Optional[datetime] + +class MemberResponse(BaseModel): + id: str + user_id: str + user_name: Optional[str] + user_avatar: Optional[str] + role: str + status: str + joined_at: Optional[datetime] + +# ============ 工具函数 ============ +def generate_group_code(db: Session) -> str: + """生成群编码:年+序号,如 26001""" + year = datetime.now().year % 100 # 26 + # 查找当前年份最大的编号 + last = db.query(PurchaseGroup).filter( + PurchaseGroup.group_code.like(f"{year}%") + ).order_by(PurchaseGroup.group_code.desc()).first() + + if last and last.group_code: + try: + num = int(last.group_code) + 1 + except: + num = 1 + else: + num = 1 + + return f"{year}{num:03d}" # 26001 # ============ 认购群API ============ @@ -59,7 +124,8 @@ def get_my_groups( ): """获取我参与的认购群""" member_groups = db.query(PurchaseMember).filter( - PurchaseMember.user_id == current_user.f99_90_id + PurchaseMember.user_id == current_user.f99_90_id, + PurchaseMember.status == "approved" ).all() group_ids = [m.group_id for m in member_groups] @@ -69,45 +135,123 @@ def get_my_groups( return groups +@router.get("/groups/{group_id}/pending-members", response_model=List[MemberResponse]) +def get_pending_members( + group_id: str, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取待审批成员列表""" + group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() + if not group: + raise HTTPException(status_code=404, detail="认购群不存在") + + if group.creator_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="只有群主可以查看") + + members = db.query(PurchaseMember).filter( + PurchaseMember.group_id == group_id, + PurchaseMember.status == "pending" + ).order_by(PurchaseMember.joined_at.asc()).all() + + return members + +@router.post("/groups/{group_id}/members/{member_id}/approve") +def approve_member( + group_id: str, + member_id: str, + action: str = Query(..., description="approve/reject"), + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """审批或拒绝成员""" + group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() + if not group: + raise HTTPException(status_code=404, detail="认购群不存在") + + if group.creator_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="只有群主可以审批") + + member = db.query(PurchaseMember).filter( + PurchaseMember.id == member_id, + PurchaseMember.group_id == group_id + ).first() + + if not member: + raise HTTPException(status_code=404, detail="成员不存在") + + if action == "approve": + member.status = "approved" + group.total_members += 1 + group.total_pending -= 1 + db.commit() + return {"message": "已批准加入"} + elif action == "reject": + member.status = "rejected" + group.total_pending -= 1 + db.commit() + return {"message": "已拒绝加入"} + else: + raise HTTPException(status_code=400, detail="无效操作") + @router.post("/groups", response_model=PurchaseGroupResponse) def create_group( data: PurchaseGroupCreate, current_user = Depends(get_current_user), db: Session = Depends(get_db) ): - """创建认购群(创建者即为群主)""" + """创建认购群""" if not current_user: raise HTTPException(status_code=401, detail="请先登录") + # 普通用户最多创建3个认购群 + if current_user.role not in ["admin", "superadmin"]: + existing_count = db.query(PurchaseGroup).filter( + PurchaseGroup.creator_id == current_user.f99_90_id + ).count() + if existing_count >= 3: + raise HTTPException(status_code=400, detail="普通用户最多创建3个认购群") + + group_code = generate_group_code(db) + + # 处理日期 + start_date = None + if data.start_date: + try: + start_date = datetime.fromisoformat(data.start_date.replace('Z', '+08:00')) + except: + start_date = datetime.now() + group = PurchaseGroup( + group_code=group_code, name=data.name, description=data.description, creator_id=current_user.f99_90_id, creator_name=current_user.f01_01_name, - status="active" + status="active", + cycle_months=data.cycle_months, + start_date=start_date ) db.add(group) db.commit() - # 创建者自动加入并成为群主 member = PurchaseMember( group_id=group.id, user_id=current_user.f99_90_id, user_name=current_user.f01_01_name, user_avatar=current_user.avatar, - role="creator" + role="creator", + status="approved" ) db.add(member) + group.total_members = 1 db.commit() db.refresh(group) return group @router.get("/groups/{group_id}", response_model=PurchaseGroupResponse) -def get_group( - group_id: str, - db: Session = Depends(get_db) -): +def get_group(group_id: str, db: Session = Depends(get_db)): """获取认购群详情""" group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() if not group: @@ -120,11 +264,10 @@ def join_group( current_user = Depends(get_current_user), db: Session = Depends(get_db) ): - """加入认购群""" + """申请加入认购群""" if not current_user: raise HTTPException(status_code=401, detail="请先登录") - # 检查群是否存在 group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() if not group: raise HTTPException(status_code=404, detail="认购群不存在") @@ -132,39 +275,47 @@ def join_group( if group.status != "active": raise HTTPException(status_code=400, detail="该认购群已结束") - # 检查是否已加入 existing = db.query(PurchaseMember).filter( PurchaseMember.group_id == group_id, PurchaseMember.user_id == current_user.f99_90_id ).first() if existing: - raise HTTPException(status_code=400, detail="您已加入该认购群") + if existing.status == "approved": + raise HTTPException(status_code=400, detail="您已加入该认购群") + elif existing.status == "pending": + raise HTTPException(status_code=400, detail="您已申请加入,等待审批") + elif existing.status == "rejected": + existing.status = "pending" + existing.joined_at = datetime.now() + group.total_pending += 1 + db.commit() + return {"message": "已重新申请,等待审批"} - # 加入 member = PurchaseMember( group_id=group_id, user_id=current_user.f99_90_id, user_name=current_user.f01_01_name, user_avatar=current_user.avatar, - role="member" + role="member", + status="pending" ) db.add(member) - - # 更新群成员数 - group.total_members += 1 + group.total_pending += 1 db.commit() - return {"message": "加入成功"} + return {"message": "申请成功,等待群主审批"} -@router.get("/groups/{group_id}/members") +@router.get("/groups/{group_id}/members", response_model=List[MemberResponse]) def get_group_members( group_id: str, + current_user = Depends(get_current_user), db: Session = Depends(get_db) ): """获取认购群成员列表""" members = db.query(PurchaseMember).filter( - PurchaseMember.group_id == group_id + PurchaseMember.group_id == group_id, + PurchaseMember.status == "approved" ).order_by(PurchaseMember.joined_at.asc()).all() return [ @@ -174,16 +325,14 @@ def get_group_members( "user_name": m.user_name, "user_avatar": m.user_avatar, "role": m.role, + "status": m.status, "joined_at": m.joined_at } for m in members ] -@router.get("/groups/{group_id}/collections") -def get_group_collections( - group_id: str, - db: Session = Depends(get_db) -): +@router.get("/groups/{group_id}/collections", response_model=List[PurchaseCollectionResponse]) +def get_group_collections(group_id: str, db: Session = Depends(get_db)): """获取认购群藏品列表""" collections = db.query(PurchaseCollection).filter( PurchaseCollection.group_id == group_id @@ -192,19 +341,25 @@ def get_group_collections( return [ { "id": c.id, + "group_id": c.group_id, "user_id": c.user_id, "user_name": c.user_name, "collection_name": c.collection_name, "collection_code": c.collection_code, "number": c.number, + "grading": c.grading, + "boss_name": c.boss_name, "price": float(c.price), + "source": c.source, + "paid": c.paid, "status": c.status, + "submit_date": c.submit_date, "submitted_at": c.submitted_at } for c in collections ] -@router.post("/groups/{group_id}/collections") +@router.post("/groups/{group_id}/collections", response_model=PurchaseCollectionResponse) def add_collection( group_id: str, data: PurchaseCollectionCreate, @@ -215,35 +370,63 @@ def add_collection( if not current_user: raise HTTPException(status_code=401, detail="请先登录") - # 检查群是否存在 + if not data.number: + raise HTTPException(status_code=400, detail="冠字号必填") + if not data.grading: + raise HTTPException(status_code=400, detail="评级必填") + if not data.boss_name: + raise HTTPException(status_code=400, detail="认购老板必填") + if not data.price: + raise HTTPException(status_code=400, detail="价格必填") + if not data.source: + raise HTTPException(status_code=400, detail="来源必填") + group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() if not group: raise HTTPException(status_code=404, detail="认购群不存在") - # 检查是否已加入 member = db.query(PurchaseMember).filter( PurchaseMember.group_id == group_id, - PurchaseMember.user_id == current_user.f99_90_id + PurchaseMember.user_id == current_user.f99_90_id, + PurchaseMember.status == "approved" ).first() if not member: - raise HTTPException(status_code=400, detail="请先加入该认购群") + raise HTTPException(status_code=400, detail="请先加入该认购群并通过审批") + + max_code = db.query(PurchaseCollection).filter( + PurchaseCollection.group_id == group_id + ).order_by(PurchaseCollection.collection_code.desc()).first() + + if max_code and max_code.collection_code: + try: + next_num = int(max_code.collection_code) + 1 + except: + next_num = 1 + else: + next_num = 1 + + collection_code = f"{next_num:03d}" - # 添加藏品 collection = PurchaseCollection( group_id=group_id, user_id=current_user.f99_90_id, user_name=current_user.f01_01_name, collection_name=data.collection_name, - collection_code=data.collection_code, + collection_code=collection_code, number=data.number, - price=data.price + grading=data.grading, + boss_name=data.boss_name, + price=data.price, + source=data.source, + paid=data.paid, + status="pending" ) db.add(collection) - # 更新群统计 group.total_collections += 1 - group.total_amount += float(data.price) + from decimal import Decimal + group.total_amount += Decimal(str(data.price)) if group.total_collections > 0: group.avg_price = group.total_amount / group.total_collections @@ -252,13 +435,77 @@ def add_collection( return collection +@router.put("/groups/{group_id}/collections/{collection_id}", response_model=PurchaseCollectionResponse) +def update_collection( + group_id: str, + collection_id: str, + data: PurchaseCollectionUpdate, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """编辑认购藏品 (仅群主可操作)""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() + if not group: + raise HTTPException(status_code=404, detail="认购群不存在") + + if group.creator_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="只有群主可以编辑") + + collection = db.query(PurchaseCollection).filter( + PurchaseCollection.id == collection_id, + PurchaseCollection.group_id == group_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="藏品不存在") + + old_price = float(collection.price) + + # 更新字段 + if data.collection_name is not None: + collection.collection_name = data.collection_name + if data.number is not None: + collection.number = data.number + if data.grading is not None: + collection.grading = data.grading + if data.boss_name is not None: + collection.boss_name = data.boss_name + if data.source is not None: + collection.source = data.source + if data.paid is not None: + collection.paid = data.paid + + # 处理状态变化 + new_status = data.status + if new_status and new_status != collection.status: + if new_status == "confirmed" and collection.status != "confirmed": + group.total_confirmed += 1 + elif collection.status == "confirmed" and new_status != "confirmed": + group.total_confirmed -= 1 + collection.status = new_status + + # 处理价格变化 + if data.price is not None and data.price != old_price: + from decimal import Decimal + group.total_amount = group.total_amount - Decimal(str(old_price)) + Decimal(str(data.price)) + if group.total_collections > 0: + group.avg_price = group.total_amount / group.total_collections + + db.commit() + db.refresh(collection) + + return collection + @router.post("/groups/{group_id}/close") def close_group( group_id: str, current_user = Depends(get_current_user), db: Session = Depends(get_db) ): - """结束认购群(仅群主可操作)""" + """结束认购群""" if not current_user: raise HTTPException(status_code=401, detail="请先登录") @@ -266,11 +513,130 @@ def close_group( if not group: raise HTTPException(status_code=404, detail="认购群不存在") - # 检查是否为群主 if group.creator_id != current_user.f99_90_id: raise HTTPException(status_code=403, detail="只有群主可以结束认购群") group.status = "closed" db.commit() - return {"message": "认购群已结束"} \ No newline at end of file + return {"message": "认购群已结束"} +@router.delete("/groups/{group_id}") +def delete_group( + group_id: str, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除认购群 (仅管理员可操作)""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + # 管理员权限检查 + if current_user.role not in ["admin", "superadmin"]: + raise HTTPException(status_code=403, detail="只有管理员可以删除认购群") + + group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() + if not group: + raise HTTPException(status_code=404, detail="认购群不存在") + + # 删除相关成员和藏品 + db.query(PurchaseMember).filter(PurchaseMember.group_id == group_id).delete() + db.query(PurchaseCollection).filter(PurchaseCollection.group_id == group_id).delete() + db.delete(group) + db.commit() + + return {"message": "认购群已删除"} + +class MemberUpdate(BaseModel): + wechat_name: Optional[str] = None + contact: Optional[str] = None + purchase_count: Optional[int] = None + paid_amount: Optional[float] = None + notes: Optional[str] = None + +class GroupUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + cycle_months: Optional[int] = None + start_date: Optional[str] = None + +# 更新成员信息 +@router.put("/groups/{group_id}/members/{member_id}") +def update_member( + group_id: str, + member_id: str, + data: MemberUpdate, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新成员信息 (成员自己或群主可编辑)""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() + if not group: + raise HTTPException(status_code=404, detail="认购群不存在") + + member = db.query(PurchaseMember).filter( + PurchaseMember.id == member_id, + PurchaseMember.group_id == group_id + ).first() + + if not member: + raise HTTPException(status_code=404, detail="成员不存在") + + # 只有成员自己或群主可以修改 + if member.user_id != current_user.f99_90_id and group.creator_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权修改") + + if data.wechat_name is not None: + member.wechat_name = data.wechat_name + if data.contact is not None: + member.contact = data.contact + if data.purchase_count is not None: + member.purchase_count = data.purchase_count + if data.paid_amount is not None: + from decimal import Decimal + member.paid_amount = Decimal(str(data.paid_amount)) + if data.notes is not None: + member.notes = data.notes + + db.commit() + db.refresh(member) + + return {"message": "更新成功", "member_id": member.id} + +# 更新群信息 +@router.put("/groups/{group_id}", response_model=PurchaseGroupResponse) +def update_group( + group_id: str, + data: GroupUpdate, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新群信息 (仅群主可编辑)""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first() + if not group: + raise HTTPException(status_code=404, detail="认购群不存在") + + if group.creator_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="只有群主可以修改") + + if data.name is not None: + group.name = data.name + if data.description is not None: + group.description = data.description + if data.cycle_months is not None: + group.cycle_months = data.cycle_months + if data.start_date is not None: + try: + group.start_date = datetime.fromisoformat(data.start_date.replace('Z', '+08:00')) + except: + pass + + db.commit() + db.refresh(group) + + return group diff --git a/config/CHANGELOG.md b/config/CHANGELOG.md index 147ab49..976932c 100644 --- a/config/CHANGELOG.md +++ b/config/CHANGELOG.md @@ -1,5 +1,28 @@ # 版本更新记录 +## v0.0.12 (2026-04-29) + +### 前端更新 +- **Purchase.jsx** + - 完善:入群申请审批流程 (待审批成员列表) + - 完善:新增认购藏品字段 + - 编号:自动生成001开始 + - 冠字号:必填 + - 评级:必填 (下拉选择) + - 认购老板:必填 (从成员中选择) + - 价格:必填 + - 来源:必填 (一尘/直播/群友/其他) + - 认购款结清:checkbox + - 优化:表单布局 + +### 后端更新 +- **purchase.py** + - 新增:入群审批接口 /approve + - 新增:待审批成员列表 /pending-members + - 新增:认购藏品新字段 (grading, boss_name, source, paid, submit_date) + +--- + ## v0.0.7 (2026-04-24) ### 代码清理 diff --git a/config/VERSION.json b/config/VERSION.json index a263464..e9f1fd4 100644 --- a/config/VERSION.json +++ b/config/VERSION.json @@ -1,15 +1,15 @@ { - "version": "0.0.9", + "version": "0.0.12", "updated": "2026-04-24", "modules": { "frontend": { - "version": "0.0.9", + "version": "0.0.12", "pages": { "Add": "0.0.1", "Admin": "0.0.1", "Detail": "0.0.1", "Edit": "0.0.1", - "Home": "0.0.4", + "Home": "0.0.7", "List": "0.0.1", "Login": "0.0.1", "News": "0.0.8", @@ -23,7 +23,7 @@ } }, "backend": { - "version": "0.0.9", + "version": "0.0.12", "routers": { "auth": "0.0.1", "collections": "0.0.1", diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index cf3cb1d..76bf281 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -226,7 +226,7 @@ export default function Home() {