Files
ai_web/backend/apps/bounties/api.py
2026-01-28 16:00:56 +08:00

850 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)