850 lines
32 KiB
Python
850 lines
32 KiB
Python
"""
|
||
Bounties API routes for tasks, applications and comments.
|
||
"""
|
||
from typing import List, Optional
|
||
from datetime import datetime
|
||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||
from ninja import Router, Query
|
||
from ninja.errors import HttpError
|
||
from ninja_jwt.authentication import JWTAuth
|
||
from ninja.pagination import paginate, PageNumberPagination
|
||
from django.conf import settings
|
||
from django.db import transaction
|
||
from django.db.models import Count, Q
|
||
from django.shortcuts import get_object_or_404
|
||
from django.utils import timezone
|
||
|
||
from .models import (
|
||
Bounty,
|
||
BountyApplication,
|
||
BountyComment,
|
||
BountyDelivery,
|
||
BountyDispute,
|
||
BountyReview,
|
||
BountyExtensionRequest,
|
||
)
|
||
from .schemas import (
|
||
BountyOut, BountyIn, BountyUpdate, BountyWithDetailsOut,
|
||
BountyApplicationOut, BountyApplicationIn,
|
||
BountyCommentOut, BountyCommentIn,
|
||
BountyFilter, MessageOut,
|
||
BountyDeliveryOut, BountyDeliveryIn, BountyDeliveryReviewIn,
|
||
BountyDisputeOut, BountyDisputeIn, BountyDisputeResolveIn,
|
||
BountyReviewOut, BountyReviewIn,
|
||
BountyExtensionRequestOut, BountyExtensionRequestIn, BountyExtensionReviewIn,
|
||
)
|
||
from apps.common.serializers import serialize_user, serialize_bounty
|
||
from apps.notifications.models import Notification
|
||
from apps.notifications.utils import should_notify
|
||
|
||
router = Router()
|
||
|
||
|
||
def parse_reward(raw_reward) -> Decimal:
|
||
"""Parse and normalize reward value."""
|
||
if raw_reward is None:
|
||
raise ValueError("reward is required")
|
||
try:
|
||
if isinstance(raw_reward, Decimal):
|
||
value = raw_reward
|
||
else:
|
||
value = Decimal(str(raw_reward).replace(",", "").strip())
|
||
if value.is_nan() or value.is_infinite():
|
||
raise ValueError("reward must be a valid number")
|
||
# Quantize to 2 decimal places
|
||
value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||
min_reward = getattr(settings, "BOUNTY_MIN_REWARD", Decimal("0.01"))
|
||
max_reward = getattr(settings, "BOUNTY_MAX_REWARD", Decimal("99999999.99"))
|
||
if value < min_reward:
|
||
raise ValueError(f"reward must be at least {min_reward}")
|
||
if value > max_reward:
|
||
raise ValueError("reward exceeds maximum allowed value")
|
||
return value
|
||
except InvalidOperation:
|
||
raise ValueError("reward must be a valid number")
|
||
|
||
|
||
# ==================== Bounty Routes ====================
|
||
|
||
@router.get("/", response=List[BountyWithDetailsOut])
|
||
@paginate(PageNumberPagination, page_size=20)
|
||
def list_bounties(request, filters: BountyFilter = Query(...)):
|
||
"""Get all bounties with optional filters."""
|
||
queryset = (
|
||
Bounty.objects.select_related('publisher', 'acceptor')
|
||
.annotate(
|
||
applications_count=Count('applications', distinct=True),
|
||
comments_count=Count('comments', distinct=True),
|
||
)
|
||
.all()
|
||
)
|
||
|
||
if filters.status:
|
||
queryset = queryset.filter(status=filters.status)
|
||
if filters.publisher_id:
|
||
queryset = queryset.filter(publisher_id=filters.publisher_id)
|
||
if filters.acceptor_id:
|
||
queryset = queryset.filter(acceptor_id=filters.acceptor_id)
|
||
|
||
return queryset
|
||
|
||
|
||
@router.get("/search/", response=List[BountyWithDetailsOut])
|
||
@paginate(PageNumberPagination, page_size=20)
|
||
def search_bounties(request, q: str):
|
||
"""Search bounties by title or description."""
|
||
queryset = (
|
||
Bounty.objects.select_related('publisher', 'acceptor')
|
||
.annotate(
|
||
applications_count=Count('applications', distinct=True),
|
||
comments_count=Count('comments', distinct=True),
|
||
)
|
||
.filter(Q(title__icontains=q) | Q(description__icontains=q))
|
||
)
|
||
return queryset
|
||
|
||
|
||
@router.get("/my-published/", response=List[BountyWithDetailsOut], auth=JWTAuth())
|
||
@paginate(PageNumberPagination, page_size=20)
|
||
def my_published_bounties(request):
|
||
"""Get bounties published by current user."""
|
||
queryset = (
|
||
Bounty.objects.select_related('publisher', 'acceptor')
|
||
.annotate(
|
||
applications_count=Count('applications', distinct=True),
|
||
comments_count=Count('comments', distinct=True),
|
||
)
|
||
.filter(publisher=request.auth)
|
||
)
|
||
return queryset
|
||
|
||
|
||
@router.get("/my-accepted/", response=List[BountyWithDetailsOut], auth=JWTAuth())
|
||
@paginate(PageNumberPagination, page_size=20)
|
||
def my_accepted_bounties(request):
|
||
"""Get bounties accepted by current user."""
|
||
queryset = (
|
||
Bounty.objects.select_related('publisher', 'acceptor')
|
||
.annotate(
|
||
applications_count=Count('applications', distinct=True),
|
||
comments_count=Count('comments', distinct=True),
|
||
)
|
||
.filter(acceptor=request.auth)
|
||
)
|
||
return queryset
|
||
|
||
|
||
@router.get("/{bounty_id}", response=BountyWithDetailsOut)
|
||
def get_bounty(request, bounty_id: int):
|
||
"""Get bounty by ID."""
|
||
bounty = get_object_or_404(
|
||
Bounty.objects.select_related('publisher', 'acceptor').annotate(
|
||
applications_count=Count('applications', distinct=True),
|
||
comments_count=Count('comments', distinct=True),
|
||
),
|
||
id=bounty_id
|
||
)
|
||
return serialize_bounty(bounty, include_counts=True)
|
||
|
||
|
||
@router.post("/", response=BountyOut, auth=JWTAuth())
|
||
def create_bounty(request, data: BountyIn):
|
||
"""Create a new bounty."""
|
||
# 标题和描述验证
|
||
if not data.title or len(data.title.strip()) < 2:
|
||
raise HttpError(400, "标题至少需要2个字符")
|
||
if len(data.title) > 200:
|
||
raise HttpError(400, "标题不能超过200个字符")
|
||
if not data.description or len(data.description.strip()) < 10:
|
||
raise HttpError(400, "描述至少需要10个字符")
|
||
if len(data.description) > 5000:
|
||
raise HttpError(400, "描述不能超过5000个字符")
|
||
|
||
# 截止时间验证
|
||
if data.deadline:
|
||
if data.deadline <= timezone.now():
|
||
raise HttpError(400, "截止时间必须是未来的时间")
|
||
|
||
payload = data.dict()
|
||
try:
|
||
payload["reward"] = parse_reward(payload.get("reward"))
|
||
except (InvalidOperation, ValueError):
|
||
raise HttpError(400, "赏金金额无效,请输入有效数字(最大 99999999.99)")
|
||
bounty = Bounty.objects.create(**payload, publisher=request.auth)
|
||
return serialize_bounty(bounty)
|
||
|
||
|
||
@router.patch("/{bounty_id}", response=BountyOut, auth=JWTAuth())
|
||
def update_bounty(request, bounty_id: int, data: BountyUpdate):
|
||
"""Update a bounty (only by publisher)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
|
||
if bounty.publisher_id != request.auth.id:
|
||
raise HttpError(403, "只有发布者可以更新此悬赏")
|
||
|
||
if bounty.status != Bounty.Status.OPEN:
|
||
raise HttpError(400, "只能更新开放中的悬赏")
|
||
|
||
update_data = data.dict(exclude_unset=True)
|
||
if "reward" in update_data:
|
||
try:
|
||
update_data["reward"] = parse_reward(update_data.get("reward"))
|
||
except (InvalidOperation, ValueError):
|
||
raise HttpError(400, "赏金金额无效,请输入有效数字(最大 99999999.99)")
|
||
for key, value in update_data.items():
|
||
setattr(bounty, key, value)
|
||
|
||
bounty.save()
|
||
return serialize_bounty(bounty)
|
||
|
||
|
||
@router.post("/{bounty_id}/cancel", response=MessageOut, auth=JWTAuth())
|
||
def cancel_bounty(request, bounty_id: int):
|
||
"""Cancel a bounty (only by publisher)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
|
||
if bounty.publisher_id != request.auth.id:
|
||
raise HttpError(403, "只有发布者可以取消此悬赏")
|
||
|
||
if bounty.status not in [Bounty.Status.OPEN, Bounty.Status.IN_PROGRESS]:
|
||
raise HttpError(400, "无法取消此悬赏")
|
||
|
||
# 如果已托管资金且处于进行中状态,需要处理退款
|
||
if bounty.is_escrowed and bounty.status == Bounty.Status.IN_PROGRESS:
|
||
raise HttpError(400, "已托管资金的进行中悬赏无法直接取消,请联系客服处理退款")
|
||
|
||
# 如果只是开放状态且已托管,标记需要退款
|
||
refund_needed = bounty.is_escrowed and bounty.status == Bounty.Status.OPEN
|
||
|
||
bounty.status = Bounty.Status.CANCELLED
|
||
bounty.save()
|
||
|
||
# Notify acceptor if exists
|
||
if bounty.acceptor and should_notify(bounty.acceptor, Notification.Type.SYSTEM):
|
||
Notification.objects.create(
|
||
user=bounty.acceptor,
|
||
type=Notification.Type.SYSTEM,
|
||
title="悬赏已取消",
|
||
content=f"您接取的悬赏 \"{bounty.title}\" 已被取消",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
|
||
message = "悬赏已取消"
|
||
if refund_needed:
|
||
message = "悬赏已取消,托管资金将在3-5个工作日内退回"
|
||
|
||
return MessageOut(message=message, success=True)
|
||
|
||
|
||
@router.post("/{bounty_id}/complete", response=MessageOut, auth=JWTAuth())
|
||
def complete_bounty(request, bounty_id: int):
|
||
"""Mark a bounty as completed (only by publisher)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
|
||
if bounty.publisher_id != request.auth.id:
|
||
raise HttpError(403, "只有发布者可以完成此悬赏")
|
||
|
||
if bounty.status != Bounty.Status.IN_PROGRESS:
|
||
raise HttpError(400, "悬赏必须处于进行中状态才能完成")
|
||
|
||
if not bounty.deliveries.filter(status=BountyDelivery.Status.ACCEPTED).exists():
|
||
raise HttpError(400, "需先验收交付内容后才能完成")
|
||
|
||
bounty.status = Bounty.Status.COMPLETED
|
||
bounty.completed_at = timezone.now()
|
||
bounty.save()
|
||
|
||
# Notify acceptor
|
||
if bounty.acceptor and should_notify(bounty.acceptor, Notification.Type.BOUNTY_COMPLETED):
|
||
Notification.objects.create(
|
||
user=bounty.acceptor,
|
||
type=Notification.Type.BOUNTY_COMPLETED,
|
||
title="悬赏已完成",
|
||
content=f"您完成的悬赏 \"{bounty.title}\" 已被确认完成",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
|
||
return MessageOut(message="悬赏已完成", success=True)
|
||
|
||
|
||
# ==================== Application Routes ====================
|
||
|
||
@router.get("/{bounty_id}/applications/", response=List[BountyApplicationOut])
|
||
def list_applications(request, bounty_id: int):
|
||
"""Get all applications for a bounty."""
|
||
applications = BountyApplication.objects.select_related('applicant').filter(
|
||
bounty_id=bounty_id
|
||
)
|
||
|
||
return [
|
||
BountyApplicationOut(
|
||
id=app.id,
|
||
bounty_id=app.bounty_id,
|
||
applicant_id=app.applicant_id,
|
||
applicant=serialize_user(app.applicant),
|
||
message=app.message,
|
||
status=app.status,
|
||
created_at=app.created_at,
|
||
updated_at=app.updated_at,
|
||
)
|
||
for app in applications
|
||
]
|
||
|
||
|
||
@router.get("/{bounty_id}/my-application/", response=Optional[BountyApplicationOut], auth=JWTAuth())
|
||
def my_application(request, bounty_id: int):
|
||
"""Get current user's application for a bounty."""
|
||
try:
|
||
app = BountyApplication.objects.select_related('applicant').get(
|
||
bounty_id=bounty_id,
|
||
applicant=request.auth
|
||
)
|
||
return BountyApplicationOut(
|
||
id=app.id,
|
||
bounty_id=app.bounty_id,
|
||
applicant_id=app.applicant_id,
|
||
applicant=serialize_user(app.applicant),
|
||
message=app.message,
|
||
status=app.status,
|
||
created_at=app.created_at,
|
||
updated_at=app.updated_at,
|
||
)
|
||
except BountyApplication.DoesNotExist:
|
||
return None
|
||
|
||
|
||
@router.post("/{bounty_id}/applications/", response=BountyApplicationOut, auth=JWTAuth())
|
||
def submit_application(request, bounty_id: int, data: BountyApplicationIn):
|
||
"""Submit an application for a bounty."""
|
||
# 申请消息长度验证
|
||
if data.message and len(data.message) > 1000:
|
||
raise HttpError(400, "申请消息不能超过1000个字符")
|
||
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
|
||
if bounty.status != Bounty.Status.OPEN:
|
||
raise HttpError(400, "无法申请此悬赏")
|
||
|
||
if bounty.publisher_id == request.auth.id:
|
||
raise HttpError(400, "不能申请自己发布的悬赏")
|
||
|
||
# Check if already applied
|
||
if BountyApplication.objects.filter(bounty=bounty, applicant=request.auth).exists():
|
||
raise HttpError(400, "您已经申请过了")
|
||
|
||
app = BountyApplication.objects.create(
|
||
bounty=bounty,
|
||
applicant=request.auth,
|
||
message=data.message,
|
||
)
|
||
|
||
# Notify publisher
|
||
if should_notify(bounty.publisher, Notification.Type.SYSTEM):
|
||
Notification.objects.create(
|
||
user=bounty.publisher,
|
||
type=Notification.Type.SYSTEM,
|
||
title="收到新申请",
|
||
content=f"您的悬赏 \"{bounty.title}\" 收到了新的申请",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
|
||
return BountyApplicationOut(
|
||
id=app.id,
|
||
bounty_id=app.bounty_id,
|
||
applicant_id=app.applicant_id,
|
||
applicant=serialize_user(app.applicant),
|
||
message=app.message,
|
||
status=app.status,
|
||
created_at=app.created_at,
|
||
updated_at=app.updated_at,
|
||
)
|
||
|
||
|
||
@router.post("/{bounty_id}/applications/{application_id}/accept", response=MessageOut, auth=JWTAuth())
|
||
def accept_application(request, bounty_id: int, application_id: int):
|
||
"""Accept an application (only by bounty publisher)."""
|
||
with transaction.atomic():
|
||
# 使用 select_for_update 加锁防止并发
|
||
bounty = Bounty.objects.select_for_update().filter(id=bounty_id).first()
|
||
if not bounty:
|
||
raise HttpError(404, "悬赏不存在")
|
||
|
||
if bounty.publisher_id != request.auth.id:
|
||
raise HttpError(403, "只有发布者可以接受申请")
|
||
|
||
if bounty.status != Bounty.Status.OPEN:
|
||
raise HttpError(400, "无法接受此悬赏的申请")
|
||
|
||
app = BountyApplication.objects.select_for_update().filter(
|
||
id=application_id, bounty_id=bounty_id
|
||
).first()
|
||
if not app:
|
||
raise HttpError(404, "申请不存在")
|
||
|
||
if app.status != BountyApplication.Status.PENDING:
|
||
raise HttpError(400, "该申请已被处理")
|
||
|
||
# Accept this application
|
||
app.status = BountyApplication.Status.ACCEPTED
|
||
app.save()
|
||
|
||
# Reject other applications
|
||
BountyApplication.objects.filter(
|
||
bounty=bounty
|
||
).exclude(id=application_id).update(status=BountyApplication.Status.REJECTED)
|
||
|
||
# Update bounty
|
||
bounty.acceptor = app.applicant
|
||
bounty.status = Bounty.Status.IN_PROGRESS
|
||
bounty.save()
|
||
|
||
# Notify acceptor (outside transaction for better performance)
|
||
if should_notify(app.applicant, Notification.Type.BOUNTY_ACCEPTED):
|
||
Notification.objects.create(
|
||
user=app.applicant,
|
||
type=Notification.Type.BOUNTY_ACCEPTED,
|
||
title="申请已被接受",
|
||
content=f"您对悬赏 \"{bounty.title}\" 的申请已被接受",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
|
||
return MessageOut(message="已接受申请", success=True)
|
||
|
||
|
||
# ==================== Comment Routes ====================
|
||
|
||
@router.get("/{bounty_id}/comments/", response=List[BountyCommentOut])
|
||
def list_comments(request, bounty_id: int):
|
||
"""Get all comments for a bounty."""
|
||
comments = BountyComment.objects.select_related('user').filter(
|
||
bounty_id=bounty_id,
|
||
parent__isnull=True # Only get top-level comments
|
||
).prefetch_related('replies', 'replies__user')
|
||
|
||
def serialize_comment(comment):
|
||
return BountyCommentOut(
|
||
id=comment.id,
|
||
bounty_id=comment.bounty_id,
|
||
user_id=comment.user_id,
|
||
user=serialize_user(comment.user),
|
||
content=comment.content,
|
||
parent_id=comment.parent_id,
|
||
replies=[serialize_comment(r) for r in comment.replies.all()],
|
||
created_at=comment.created_at,
|
||
updated_at=comment.updated_at,
|
||
)
|
||
|
||
return [serialize_comment(c) for c in comments]
|
||
|
||
|
||
@router.post("/{bounty_id}/comments/", response=BountyCommentOut, auth=JWTAuth())
|
||
def create_comment(request, bounty_id: int, data: BountyCommentIn):
|
||
"""Create a comment on a bounty."""
|
||
# 评论内容验证
|
||
if not data.content or len(data.content.strip()) < 1:
|
||
raise HttpError(400, "评论内容不能为空")
|
||
if len(data.content) > 2000:
|
||
raise HttpError(400, "评论内容不能超过2000个字符")
|
||
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
|
||
comment = BountyComment.objects.create(
|
||
bounty=bounty,
|
||
user=request.auth,
|
||
content=data.content,
|
||
parent_id=data.parent_id,
|
||
)
|
||
|
||
# Notify bounty publisher (if not commenting on own bounty)
|
||
if bounty.publisher_id != request.auth.id and should_notify(bounty.publisher, Notification.Type.NEW_COMMENT):
|
||
Notification.objects.create(
|
||
user=bounty.publisher,
|
||
type=Notification.Type.NEW_COMMENT,
|
||
title="收到新评论",
|
||
content=f"您的悬赏 \"{bounty.title}\" 收到了新评论",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
|
||
# Notify parent comment author (if replying)
|
||
if data.parent_id:
|
||
parent = get_object_or_404(
|
||
BountyComment.objects.select_related("user"), id=data.parent_id
|
||
)
|
||
if parent.user_id != request.auth.id and should_notify(parent.user, Notification.Type.NEW_COMMENT):
|
||
Notification.objects.create(
|
||
user=parent.user,
|
||
type=Notification.Type.NEW_COMMENT,
|
||
title="收到回复",
|
||
content=f"您在悬赏 \"{bounty.title}\" 的评论收到了回复",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
|
||
return BountyCommentOut(
|
||
id=comment.id,
|
||
bounty_id=comment.bounty_id,
|
||
user_id=comment.user_id,
|
||
user=serialize_user(comment.user),
|
||
content=comment.content,
|
||
parent_id=comment.parent_id,
|
||
replies=[],
|
||
created_at=comment.created_at,
|
||
updated_at=comment.updated_at,
|
||
)
|
||
|
||
|
||
# ==================== Delivery Routes ====================
|
||
|
||
@router.get("/{bounty_id}/deliveries/", response=List[BountyDeliveryOut], auth=JWTAuth())
|
||
def list_deliveries(request, bounty_id: int):
|
||
"""List deliveries for a bounty (publisher or acceptor)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id]:
|
||
raise HttpError(403, "无权限查看交付记录")
|
||
deliveries = BountyDelivery.objects.filter(bounty=bounty).order_by('-submitted_at')
|
||
return [
|
||
BountyDeliveryOut(
|
||
id=d.id,
|
||
bounty_id=d.bounty_id,
|
||
submitter_id=d.submitter_id,
|
||
content=d.content,
|
||
attachment_url=d.attachment_url,
|
||
status=d.status,
|
||
submitted_at=d.submitted_at,
|
||
reviewed_at=d.reviewed_at,
|
||
)
|
||
for d in deliveries
|
||
]
|
||
|
||
|
||
@router.post("/{bounty_id}/deliveries/", response=BountyDeliveryOut, auth=JWTAuth())
|
||
def submit_delivery(request, bounty_id: int, data: BountyDeliveryIn):
|
||
"""Submit delivery (acceptor only)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
if bounty.acceptor_id != request.auth.id:
|
||
raise HttpError(403, "只有接单者可以提交交付")
|
||
if bounty.status != Bounty.Status.IN_PROGRESS:
|
||
raise HttpError(400, "悬赏不在进行中状态")
|
||
delivery = BountyDelivery.objects.create(
|
||
bounty=bounty,
|
||
submitter=request.auth,
|
||
content=data.content,
|
||
attachment_url=data.attachment_url,
|
||
)
|
||
if should_notify(bounty.publisher, Notification.Type.SYSTEM):
|
||
Notification.objects.create(
|
||
user=bounty.publisher,
|
||
type=Notification.Type.SYSTEM,
|
||
title="收到交付内容",
|
||
content=f"悬赏 \"{bounty.title}\" 收到新的交付",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
return BountyDeliveryOut(
|
||
id=delivery.id,
|
||
bounty_id=delivery.bounty_id,
|
||
submitter_id=delivery.submitter_id,
|
||
content=delivery.content,
|
||
attachment_url=delivery.attachment_url,
|
||
status=delivery.status,
|
||
submitted_at=delivery.submitted_at,
|
||
reviewed_at=delivery.reviewed_at,
|
||
)
|
||
|
||
|
||
@router.post("/{bounty_id}/deliveries/{delivery_id}/review", response=MessageOut, auth=JWTAuth())
|
||
def review_delivery(request, bounty_id: int, delivery_id: int, data: BountyDeliveryReviewIn):
|
||
"""Accept or reject delivery (publisher only)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
if bounty.publisher_id != request.auth.id:
|
||
raise HttpError(403, "只有发布者可以验收")
|
||
delivery = get_object_or_404(BountyDelivery, id=delivery_id, bounty_id=bounty_id)
|
||
if delivery.status != BountyDelivery.Status.SUBMITTED:
|
||
raise HttpError(400, "该交付已处理")
|
||
delivery.status = BountyDelivery.Status.ACCEPTED if data.accept else BountyDelivery.Status.REJECTED
|
||
delivery.reviewed_at = timezone.now()
|
||
delivery.save()
|
||
if should_notify(delivery.submitter, Notification.Type.SYSTEM):
|
||
Notification.objects.create(
|
||
user=delivery.submitter,
|
||
type=Notification.Type.SYSTEM,
|
||
title="交付已处理",
|
||
content=f"悬赏 \"{bounty.title}\" 的交付已被处理",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
return MessageOut(message="交付已处理", success=True)
|
||
|
||
|
||
# ==================== Dispute Routes ====================
|
||
|
||
@router.get("/{bounty_id}/disputes/", response=List[BountyDisputeOut], auth=JWTAuth())
|
||
def list_disputes(request, bounty_id: int):
|
||
"""List disputes for a bounty."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id] and request.auth.role != 'admin':
|
||
raise HttpError(403, "无权限查看争议")
|
||
disputes = BountyDispute.objects.filter(bounty=bounty).order_by('-created_at')
|
||
return [
|
||
BountyDisputeOut(
|
||
id=d.id,
|
||
bounty_id=d.bounty_id,
|
||
initiator_id=d.initiator_id,
|
||
reason=d.reason,
|
||
evidence_url=d.evidence_url,
|
||
status=d.status,
|
||
resolution=d.resolution,
|
||
created_at=d.created_at,
|
||
resolved_at=d.resolved_at,
|
||
)
|
||
for d in disputes
|
||
]
|
||
|
||
|
||
@router.post("/{bounty_id}/disputes/", response=BountyDisputeOut, auth=JWTAuth())
|
||
def create_dispute(request, bounty_id: int, data: BountyDisputeIn):
|
||
"""Create a dispute (publisher or acceptor)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
|
||
# 检查悬赏状态是否允许创建争议
|
||
if bounty.status not in [Bounty.Status.IN_PROGRESS, Bounty.Status.DISPUTED]:
|
||
raise HttpError(400, "只有进行中或已有争议的悬赏才能发起争议")
|
||
|
||
# 检查权限(考虑acceptor可能为None)
|
||
allowed_users = [bounty.publisher_id]
|
||
if bounty.acceptor_id:
|
||
allowed_users.append(bounty.acceptor_id)
|
||
if request.auth.id not in allowed_users:
|
||
raise HttpError(403, "无权限发起争议")
|
||
|
||
# 检查是否已有未解决的争议
|
||
if BountyDispute.objects.filter(bounty=bounty, status=BountyDispute.Status.OPEN).exists():
|
||
raise HttpError(400, "该悬赏已有未解决的争议")
|
||
|
||
# 争议原因验证
|
||
if not data.reason or len(data.reason.strip()) < 10:
|
||
raise HttpError(400, "争议原因至少需要10个字符")
|
||
if len(data.reason) > 2000:
|
||
raise HttpError(400, "争议原因不能超过2000个字符")
|
||
|
||
dispute = BountyDispute.objects.create(
|
||
bounty=bounty,
|
||
initiator=request.auth,
|
||
reason=data.reason,
|
||
evidence_url=data.evidence_url,
|
||
)
|
||
bounty.status = Bounty.Status.DISPUTED
|
||
bounty.save()
|
||
other_user = bounty.acceptor if bounty.publisher_id == request.auth.id else bounty.publisher
|
||
if other_user and should_notify(other_user, Notification.Type.SYSTEM):
|
||
Notification.objects.create(
|
||
user=other_user,
|
||
type=Notification.Type.SYSTEM,
|
||
title="悬赏进入争议",
|
||
content=f"悬赏 \"{bounty.title}\" 被发起争议",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
return BountyDisputeOut(
|
||
id=dispute.id,
|
||
bounty_id=dispute.bounty_id,
|
||
initiator_id=dispute.initiator_id,
|
||
reason=dispute.reason,
|
||
evidence_url=dispute.evidence_url,
|
||
status=dispute.status,
|
||
resolution=dispute.resolution,
|
||
created_at=dispute.created_at,
|
||
resolved_at=dispute.resolved_at,
|
||
)
|
||
|
||
|
||
@router.post("/{bounty_id}/disputes/{dispute_id}/resolve", response=MessageOut, auth=JWTAuth())
|
||
def resolve_dispute(request, bounty_id: int, dispute_id: int, data: BountyDisputeResolveIn):
|
||
"""Resolve dispute (admin only)."""
|
||
if request.auth.role != 'admin':
|
||
raise HttpError(403, "仅管理员可处理争议")
|
||
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
dispute = get_object_or_404(BountyDispute, id=dispute_id, bounty_id=bounty_id)
|
||
|
||
if dispute.status != BountyDispute.Status.OPEN:
|
||
raise HttpError(400, "争议已处理")
|
||
|
||
dispute.status = BountyDispute.Status.RESOLVED if data.accepted else BountyDispute.Status.REJECTED
|
||
dispute.resolution = data.resolution
|
||
dispute.resolved_at = timezone.now()
|
||
dispute.save()
|
||
|
||
# 检查是否还有其他未解决的争议
|
||
has_open_disputes = BountyDispute.objects.filter(
|
||
bounty=bounty,
|
||
status=BountyDispute.Status.OPEN
|
||
).exists()
|
||
|
||
# 如果没有其他未解决的争议,将悬赏状态恢复为进行中
|
||
if not has_open_disputes and bounty.status == Bounty.Status.DISPUTED:
|
||
bounty.status = Bounty.Status.IN_PROGRESS
|
||
bounty.save()
|
||
|
||
# 通知相关用户
|
||
users_to_notify = []
|
||
if bounty.publisher and bounty.publisher_id != request.auth.id:
|
||
users_to_notify.append(bounty.publisher)
|
||
if bounty.acceptor and bounty.acceptor_id != request.auth.id:
|
||
users_to_notify.append(bounty.acceptor)
|
||
|
||
for user in users_to_notify:
|
||
if should_notify(user, Notification.Type.SYSTEM):
|
||
Notification.objects.create(
|
||
user=user,
|
||
type=Notification.Type.SYSTEM,
|
||
title="争议已处理",
|
||
content=f"悬赏 \"{bounty.title}\" 的争议已被管理员处理",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
|
||
return MessageOut(message="争议已处理", success=True)
|
||
|
||
|
||
# ==================== Review Routes ====================
|
||
|
||
@router.get("/{bounty_id}/reviews/", response=List[BountyReviewOut])
|
||
def list_reviews(request, bounty_id: int):
|
||
"""List reviews for a bounty."""
|
||
reviews = BountyReview.objects.filter(bounty_id=bounty_id).order_by('-created_at')
|
||
return [
|
||
BountyReviewOut(
|
||
id=r.id,
|
||
bounty_id=r.bounty_id,
|
||
reviewer_id=r.reviewer_id,
|
||
reviewee_id=r.reviewee_id,
|
||
rating=r.rating,
|
||
comment=r.comment,
|
||
created_at=r.created_at,
|
||
)
|
||
for r in reviews
|
||
]
|
||
|
||
|
||
@router.post("/{bounty_id}/reviews/", response=BountyReviewOut, auth=JWTAuth())
|
||
def create_review(request, bounty_id: int, data: BountyReviewIn):
|
||
"""Create review after completion."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
if bounty.status != Bounty.Status.COMPLETED:
|
||
raise HttpError(400, "悬赏未完成,无法评价")
|
||
participants = {bounty.publisher_id, bounty.acceptor_id}
|
||
if request.auth.id not in participants or data.reviewee_id not in participants:
|
||
raise HttpError(403, "无权限评价")
|
||
if request.auth.id == data.reviewee_id:
|
||
raise HttpError(400, "无法评价自己")
|
||
if not (1 <= data.rating <= 5):
|
||
raise HttpError(400, "评分需在 1-5 之间")
|
||
review = BountyReview.objects.create(
|
||
bounty=bounty,
|
||
reviewer=request.auth,
|
||
reviewee_id=data.reviewee_id,
|
||
rating=data.rating,
|
||
comment=data.comment,
|
||
)
|
||
return BountyReviewOut(
|
||
id=review.id,
|
||
bounty_id=review.bounty_id,
|
||
reviewer_id=review.reviewer_id,
|
||
reviewee_id=review.reviewee_id,
|
||
rating=review.rating,
|
||
comment=review.comment,
|
||
created_at=review.created_at,
|
||
)
|
||
|
||
|
||
# ==================== Extension Routes ====================
|
||
|
||
@router.get("/{bounty_id}/extension-requests/", response=List[BountyExtensionRequestOut], auth=JWTAuth())
|
||
def list_extension_requests(request, bounty_id: int):
|
||
"""List extension requests for a bounty."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id]:
|
||
raise HttpError(403, "无权限查看延期申请")
|
||
requests = BountyExtensionRequest.objects.filter(bounty=bounty).order_by('-created_at')
|
||
return [
|
||
BountyExtensionRequestOut(
|
||
id=r.id,
|
||
bounty_id=r.bounty_id,
|
||
requester_id=r.requester_id,
|
||
proposed_deadline=r.proposed_deadline,
|
||
reason=r.reason,
|
||
status=r.status,
|
||
created_at=r.created_at,
|
||
reviewed_at=r.reviewed_at,
|
||
)
|
||
for r in requests
|
||
]
|
||
|
||
|
||
@router.post("/{bounty_id}/extension-requests/", response=BountyExtensionRequestOut, auth=JWTAuth())
|
||
def create_extension_request(request, bounty_id: int, data: BountyExtensionRequestIn):
|
||
"""Request deadline extension (acceptor only)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
if bounty.acceptor_id != request.auth.id:
|
||
raise HttpError(403, "只有接单者可以申请延期")
|
||
if bounty.status != Bounty.Status.IN_PROGRESS:
|
||
raise HttpError(400, "悬赏不在进行中状态")
|
||
extension = BountyExtensionRequest.objects.create(
|
||
bounty=bounty,
|
||
requester=request.auth,
|
||
proposed_deadline=data.proposed_deadline,
|
||
reason=data.reason,
|
||
)
|
||
if should_notify(bounty.publisher, Notification.Type.SYSTEM):
|
||
Notification.objects.create(
|
||
user=bounty.publisher,
|
||
type=Notification.Type.SYSTEM,
|
||
title="收到延期申请",
|
||
content=f"悬赏 \"{bounty.title}\" 收到延期申请",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
return BountyExtensionRequestOut(
|
||
id=extension.id,
|
||
bounty_id=extension.bounty_id,
|
||
requester_id=extension.requester_id,
|
||
proposed_deadline=extension.proposed_deadline,
|
||
reason=extension.reason,
|
||
status=extension.status,
|
||
created_at=extension.created_at,
|
||
reviewed_at=extension.reviewed_at,
|
||
)
|
||
|
||
|
||
@router.post("/{bounty_id}/extension-requests/{request_id}/review", response=MessageOut, auth=JWTAuth())
|
||
def review_extension_request(request, bounty_id: int, request_id: int, data: BountyExtensionReviewIn):
|
||
"""Approve or reject extension request (publisher only)."""
|
||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||
if bounty.publisher_id != request.auth.id:
|
||
raise HttpError(403, "只有发布者可以处理延期申请")
|
||
extension = get_object_or_404(BountyExtensionRequest, id=request_id, bounty_id=bounty_id)
|
||
if extension.status != BountyExtensionRequest.Status.PENDING:
|
||
raise HttpError(400, "延期申请已处理")
|
||
extension.status = BountyExtensionRequest.Status.APPROVED if data.approve else BountyExtensionRequest.Status.REJECTED
|
||
extension.reviewed_at = timezone.now()
|
||
extension.save()
|
||
if data.approve:
|
||
bounty.deadline = extension.proposed_deadline
|
||
bounty.save()
|
||
if should_notify(extension.requester, Notification.Type.SYSTEM):
|
||
Notification.objects.create(
|
||
user=extension.requester,
|
||
type=Notification.Type.SYSTEM,
|
||
title="延期申请已处理",
|
||
content=f"悬赏 \"{bounty.title}\" 的延期申请已处理",
|
||
related_id=bounty.id,
|
||
related_type="bounty",
|
||
)
|
||
return MessageOut(message="延期申请已处理", success=True)
|